diff --git a/migrations/0006_add_issue.sql b/migrations/0006_add_issue.sql new file mode 100644 index 0000000..eee9ccd --- /dev/null +++ b/migrations/0006_add_issue.sql @@ -0,0 +1,75 @@ +CREATE TYPE "base"."issue_type" AS ENUM ('bug', 'task', 'epic'); + +CREATE TYPE "base"."priority" AS ENUM ('critical', 'low', 'medium', 'high'); + +CREATE TABLE + "base"."issues" ( + "id" text PRIMARY KEY NOT NULL, + "title" varchar(255) NOT NULL, + "description" text, + "description_html" text, + "priority" "priority" DEFAULT 'medium' NOT NULL, + "type" "issue_type" DEFAULT 'task' NOT NULL, + "area_id" text NOT NULL, + "state_id" text, + "position" integer DEFAULT 0, + "assignee_id" text, + "reporter_id" text, + "parent_id" text, + "story_points" integer, + "due_date" timestamp + with + time zone, + "created_at" timestamp + with + time zone DEFAULT now () NOT NULL, + "updated_at" timestamp + with + time zone DEFAULT now () NOT NULL, + "deleted_at" timestamp + with + time zone, + CONSTRAINT "no_self_parent" CHECK ( + "base"."issues"."parent_id" IS NULL + OR "base"."issues"."parent_id" != "base"."issues"."id" + ) + ); + +ALTER TABLE "base"."issues" ADD CONSTRAINT "issues_area_id_areas_id_fk" FOREIGN KEY ("area_id") REFERENCES "base"."areas" ("id") ON DELETE cascade ON UPDATE no action; + +ALTER TABLE "base"."issues" ADD CONSTRAINT "issues_state_id_states_id_fk" FOREIGN KEY ("state_id") REFERENCES "base"."states" ("id") ON DELETE set null ON UPDATE no action; + +ALTER TABLE "base"."issues" ADD CONSTRAINT "issues_assignee_id_users_id_fk" FOREIGN KEY ("assignee_id") REFERENCES "base"."users" ("id") ON DELETE set null ON UPDATE no action; + +ALTER TABLE "base"."issues" ADD CONSTRAINT "issues_reporter_id_users_id_fk" FOREIGN KEY ("reporter_id") REFERENCES "base"."users" ("id") ON DELETE set null ON UPDATE no action; + +ALTER TABLE "base"."issues" ADD CONSTRAINT "issues_parent_id_issues_id_fk" FOREIGN KEY ("parent_id") REFERENCES "base"."issues" ("id") ON DELETE set null ON UPDATE no action; + +CREATE INDEX "idx_issue_area_state" ON "base"."issues" USING btree ("area_id", "state_id", "position") +WHERE + "base"."issues"."deleted_at" IS NULL; + +CREATE INDEX "idx_issue_assignee" ON "base"."issues" USING btree ("assignee_id") +WHERE + "base"."issues"."deleted_at" IS NULL; + +CREATE INDEX "idx_issue_parent" ON "base"."issues" USING btree ("parent_id") +WHERE + "base"."issues"."deleted_at" IS NULL; + +CREATE INDEX "idx_issue_priority" ON "base"."issues" USING btree ("priority") +WHERE + "base"."issues"."deleted_at" IS NULL; + +CREATE INDEX "idx_issue_type" ON "base"."issues" USING btree ("type") +WHERE + "base"."issues"."deleted_at" IS NULL; + +CREATE INDEX "idx_issue_search" ON "base"."issues" USING gin ( + to_tsvector ( + 'english', + COALESCE("title", '') || ' ' || COALESCE("description", '') + ) +) +WHERE + "base"."issues"."deleted_at" IS NULL; \ 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..d732bde --- /dev/null +++ b/migrations/meta/0006_snapshot.json @@ -0,0 +1,2392 @@ +{ + "id": "ff097bac-bc33-47f1-a242-773529e37d66", + "prevId": "69248628-6c77-4c0b-bc3f-913a61c68731", + "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_preferences": { + "name": "user_preferences", + "schema": "base", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'system'" + }, + "timezone": { + "name": "timezone", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "language": { + "name": "language", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'ru'" + } + }, + "indexes": {}, + "foreignKeys": { + "user_preferences_user_id_users_id_fk": { + "name": "user_preferences_user_id_users_id_fk", + "tableFrom": "user_preferences", + "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": false + }, + "recovery_email": { + "name": "recovery_email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "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_login_at": { + "name": "last_login_at", + "type": "timestamp with time zone", + "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 + }, + "username": { + "name": "username", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "headline": { + "name": "headline", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "location": { + "name": "location", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "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 + }, + "phone": { + "name": "phone", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "vacation_start": { + "name": "vacation_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "vacation_end": { + "name": "vacation_end", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "vacation_message": { + "name": "vacation_message", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "gender": { + "name": "gender", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'none'" + }, + "pronouns": { + "name": "pronouns", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'none'" + }, + "pronouns_custom": { + "name": "pronouns_custom", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "email_verified_at": { + "name": "email_verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_team_id": { + "name": "last_team_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "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()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_username_unique": { + "name": "users_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + }, + "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.user_identities": { + "name": "user_identities", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "provider_user_id": { + "name": "provider_user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "avatar_url": { + "name": "avatar_url", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_identities_user_id_users_id_fk": { + "name": "user_identities_user_id_users_id_fk", + "tableFrom": "user_identities", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "provider_user_id_idx": { + "name": "provider_user_id_idx", + "nullsNotDistinct": false, + "columns": [ + "provider", + "provider_user_id" + ] + } + }, + "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 with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "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 + }, + "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 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()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "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": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.project_members": { + "name": "project_members", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_member_unique_idx": { + "name": "project_member_unique_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_member_user_idx": { + "name": "project_member_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_member_project_idx": { + "name": "project_member_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_members_project_id_projects_id_fk": { + "name": "project_members_project_id_projects_id_fk", + "tableFrom": "project_members", + "tableTo": "projects", + "schemaTo": "base", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_members_user_id_users_id_fk": { + "name": "project_members_user_id_users_id_fk", + "tableFrom": "project_members", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_members_added_by_users_id_fk": { + "name": "project_members_added_by_users_id_fk", + "tableFrom": "project_members", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "added_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.project_settings": { + "name": "project_settings", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "default_view": { + "name": "default_view", + "type": "layout_type", + "typeSchema": "base", + "primaryKey": false, + "notNull": true, + "default": "'kanban'" + }, + "task_prefix": { + "name": "task_prefix", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "auto_close_days": { + "name": "auto_close_days", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "max_tasks_per_area": { + "name": "max_tasks_per_area", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "max_members": { + "name": "max_members", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "max_areas": { + "name": "max_areas", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "allow_guests": { + "name": "allow_guests", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "time_tracking": { + "name": "time_tracking", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "time_tracking_mode": { + "name": "time_tracking_mode", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "default": "'optional'" + }, + "default_assignee_id": { + "name": "default_assignee_id", + "type": "text", + "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()" + } + }, + "indexes": { + "project_settings_project_idx": { + "name": "project_settings_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_settings_project_id_projects_id_fk": { + "name": "project_settings_project_id_projects_id_fk", + "tableFrom": "project_settings", + "tableTo": "projects", + "schemaTo": "base", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_settings_default_assignee_id_users_id_fk": { + "name": "project_settings_default_assignee_id_users_id_fk", + "tableFrom": "project_settings", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "default_assignee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "project_settings_project_id_unique": { + "name": "project_settings_project_id_unique", + "nullsNotDistinct": false, + "columns": [ + "project_id" + ] + } + }, + "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": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "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" + }, + "project_shares_created_by_users_id_fk": { + "name": "project_shares_created_by_users_id_fk", + "tableFrom": "project_shares", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "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 + }, + "slug": { + "name": "slug", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "descriptionHtml": { + "name": "descriptionHtml", + "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'" + }, + "sequence": { + "name": "sequence", + "type": "integer", + "primaryKey": false, + "notNull": false, + "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'" + }, + "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()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "project_team_slug_idx": { + "name": "project_team_slug_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "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": { + "projects_slug_unique": { + "name": "projects_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.areas": { + "name": "areas", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description_html": { + "name": "description_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "tasks_count": { + "name": "tasks_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "default_view": { + "name": "default_view", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'kanban'" + }, + "icon": { + "name": "icon", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_tasks_limit": { + "name": "max_tasks_limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_locked": { + "name": "is_locked", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": 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()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_areas_slug": { + "name": "idx_areas_slug", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_areas_project_active": { + "name": "idx_areas_project_active", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"base\".\"areas\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_areas_created_by": { + "name": "idx_areas_created_by", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"base\".\"areas\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_areas_deleted_at": { + "name": "idx_areas_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"base\".\"areas\".\"deleted_at\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "areas_project_id_projects_id_fk": { + "name": "areas_project_id_projects_id_fk", + "tableFrom": "areas", + "tableTo": "projects", + "schemaTo": "base", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "areas_created_by_users_id_fk": { + "name": "areas_created_by_users_id_fk", + "tableFrom": "areas", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "areas_slug_unique": { + "name": "areas_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.states": { + "name": "states", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "area_id": { + "name": "area_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_type": { + "name": "state_type", + "type": "state_type", + "primaryKey": false, + "notNull": true, + "default": "'custom'" + }, + "category": { + "name": "category", + "type": "state_category", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "color": { + "name": "color", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_visible": { + "name": "is_visible", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "max_tasks_limit": { + "name": "max_tasks_limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "auto_transition_to": { + "name": "auto_transition_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notify_on_enter": { + "name": "notify_on_enter", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "notify_on_exit": { + "name": "notify_on_exit", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "is_locked": { + "name": "is_locked", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": 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()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_states_position": { + "name": "idx_states_position", + "columns": [ + { + "expression": "area_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_states_title": { + "name": "idx_states_title", + "columns": [ + { + "expression": "area_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_states_created_at": { + "name": "idx_states_created_at", + "columns": [ + { + "expression": "area_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_states_search": { + "name": "idx_states_search", + "columns": [ + { + "expression": "area_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_states_unique_title": { + "name": "idx_states_unique_title", + "columns": [ + { + "expression": "area_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"base\".\"states\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_states_deleted_at": { + "name": "idx_states_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"base\".\"states\".\"deleted_at\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "states_area_id_areas_id_fk": { + "name": "states_area_id_areas_id_fk", + "tableFrom": "states", + "tableTo": "areas", + "schemaTo": "base", + "columnsFrom": [ + "area_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "states_created_by_users_id_fk": { + "name": "states_created_by_users_id_fk", + "tableFrom": "states", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.issues": { + "name": "issues", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description_html": { + "name": "description_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "priority", + "primaryKey": false, + "notNull": true, + "default": "'medium'" + }, + "type": { + "name": "type", + "type": "issue_type", + "primaryKey": false, + "notNull": true, + "default": "'task'" + }, + "area_id": { + "name": "area_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_id": { + "name": "state_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "assignee_id": { + "name": "assignee_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reporter_id": { + "name": "reporter_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "story_points": { + "name": "story_points", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "due_date": { + "name": "due_date", + "type": "timestamp with time zone", + "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()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_issue_area_state": { + "name": "idx_issue_area_state", + "columns": [ + { + "expression": "area_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"base\".\"issues\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_issue_assignee": { + "name": "idx_issue_assignee", + "columns": [ + { + "expression": "assignee_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"base\".\"issues\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_issue_parent": { + "name": "idx_issue_parent", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"base\".\"issues\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_issue_priority": { + "name": "idx_issue_priority", + "columns": [ + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"base\".\"issues\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_issue_type": { + "name": "idx_issue_type", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"base\".\"issues\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_issue_search": { + "name": "idx_issue_search", + "columns": [ + { + "expression": "to_tsvector('english', COALESCE(\"title\", '') || ' ' || COALESCE(\"description\", ''))", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"base\".\"issues\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "issues_area_id_areas_id_fk": { + "name": "issues_area_id_areas_id_fk", + "tableFrom": "issues", + "tableTo": "areas", + "schemaTo": "base", + "columnsFrom": [ + "area_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issues_state_id_states_id_fk": { + "name": "issues_state_id_states_id_fk", + "tableFrom": "issues", + "tableTo": "states", + "schemaTo": "base", + "columnsFrom": [ + "state_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "issues_assignee_id_users_id_fk": { + "name": "issues_assignee_id_users_id_fk", + "tableFrom": "issues", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "assignee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "issues_reporter_id_users_id_fk": { + "name": "issues_reporter_id_users_id_fk", + "tableFrom": "issues", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "reporter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "issues_parent_id_issues_id_fk": { + "name": "issues_parent_id_issues_id_fk", + "tableFrom": "issues", + "tableTo": "issues", + "schemaTo": "base", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "no_self_parent": { + "name": "no_self_parent", + "value": "\"base\".\"issues\".\"parent_id\" IS NULL OR \"base\".\"issues\".\"parent_id\" != \"base\".\"issues\".\"id\"" + } + }, + "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.layout_type": { + "name": "layout_type", + "schema": "base", + "values": [ + "kanban", + "list", + "calendar", + "gantt" + ] + }, + "base.project_status": { + "name": "project_status", + "schema": "base", + "values": [ + "active", + "archived", + "template", + "deleted" + ] + }, + "base.project_visibility": { + "name": "project_visibility", + "schema": "base", + "values": [ + "public", + "private" + ] + }, + "base.state_category": { + "name": "state_category", + "schema": "base", + "values": [ + "backlog", + "active", + "review", + "completed", + "archived" + ] + }, + "base.state_type": { + "name": "state_type", + "schema": "base", + "values": [ + "backlog", + "todo", + "in_progress", + "review", + "done", + "archived", + "custom" + ] + }, + "base.issue_type": { + "name": "issue_type", + "schema": "base", + "values": [ + "bug", + "task", + "epic" + ] + }, + "base.priority": { + "name": "priority", + "schema": "base", + "values": [ + "critical", + "low", + "medium", + "high" + ] + } + }, + "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 1fc04a1..f760a0f 100644 --- a/migrations/meta/_journal.json +++ b/migrations/meta/_journal.json @@ -43,6 +43,13 @@ "when": 1781463108615, "tag": "0005_add_area", "breakpoints": false + }, + { + "idx": 6, + "version": "7", + "when": 1781645047048, + "tag": "0006_add_issue", + "breakpoints": false } ] } \ No newline at end of file diff --git a/src/app.module.ts b/src/app.module.ts index 3288e74..15a2c7f 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -19,6 +19,7 @@ import { ZodValidationPipe } from 'nestjs-zod'; import { AreaModule } from './area'; import { AuthModule } from './auth/auth.module'; +import { IssueModule } from './issue'; import { ProjectModule } from './project'; import * as schema from './shared/entities'; import { TeamsModule } from './teams'; @@ -55,6 +56,7 @@ import { UserModule } from './user'; TeamsModule, ProjectModule, AreaModule, + IssueModule, MetricsModule, HealthModule.registerAsync({ inject: [DatabaseHealthService, S3Service, CACHE_SERVICE], diff --git a/src/area/application/use-cases/index.ts b/src/area/application/use-cases/index.ts index 69b00cf..4801d01 100644 --- a/src/area/application/use-cases/index.ts +++ b/src/area/application/use-cases/index.ts @@ -1,2 +1,7 @@ +import { AreasUseCases } from './areas'; +import { StatesUseCases } from './states'; + export * from './states'; export * from './areas'; + +export const USE_CASES = [...AreasUseCases, ...StatesUseCases]; diff --git a/src/area/area.module.ts b/src/area/area.module.ts index 9dfbc90..9ffe21e 100644 --- a/src/area/area.module.ts +++ b/src/area/area.module.ts @@ -3,13 +3,13 @@ import { forwardRef, Module } from '@nestjs/common'; import { AreaFacade } from './application/area.facade'; import { CONTROLLERS } from './application/controllers'; -import { AreasUseCases, StatesUseCases } from './application/use-cases'; +import { GetAreaQuery, GetStateQuery, USE_CASES } from './application/use-cases'; import { REPOSITORIES } from './infrastructure/persistence/repositories'; @Module({ imports: [forwardRef(() => ProjectModule)], controllers: [...CONTROLLERS], - providers: [...REPOSITORIES, ...StatesUseCases, ...AreasUseCases, AreaFacade], - exports: [], + providers: [...REPOSITORIES, ...USE_CASES, AreaFacade], + exports: [GetAreaQuery, GetStateQuery], }) export class AreaModule {} diff --git a/src/issue/application/controllers/index.ts b/src/issue/application/controllers/index.ts new file mode 100644 index 0000000..be17c2d --- /dev/null +++ b/src/issue/application/controllers/index.ts @@ -0,0 +1,3 @@ +import { IssuesController } from './issues/controller'; + +export const CONTROLLERS = [IssuesController]; diff --git a/src/issue/application/controllers/issues/controller.ts b/src/issue/application/controllers/issues/controller.ts new file mode 100644 index 0000000..f9673d3 --- /dev/null +++ b/src/issue/application/controllers/issues/controller.ts @@ -0,0 +1,103 @@ +import { + Body, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + Patch, + Post, + Put, + Query, +} from '@nestjs/common'; +import { ApiBaseController, GetUserId } from '@shared/decorators'; + +import { + AssignIssueDto, + CreateIssueDto, + IssueFiltersQueryDto, + IssueQueryDto, + MoveIssueDto, + UpdateIssueDto, +} from '../../dtos'; +import { IssueFacade } from '../../issue.facade'; + +import { + AssignIssueSwagger, + CreateIssueSwagger, + DeleteIssueSwagger, + GetAllIssuesSwagger, + GetOneIssueSwagger, + MoveIssueSwagger, + RestoreIssueSwagger, + UpdateIssueSwagger, +} from './swagger'; + +@ApiBaseController('issues', 'Issues', true) +export class IssuesController { + constructor(private readonly facade: IssueFacade) {} + + @Post() + @CreateIssueSwagger() + create(@Body() dto: CreateIssueDto, @Query() q: IssueQueryDto, @GetUserId() userId: string) { + return this.facade.create(dto, q.slug, q.key, userId); + } + + @Get(':id') + @GetOneIssueSwagger() + getById(@Query() q: IssueQueryDto, @Param('id') id: string, @GetUserId() userId: string) { + return this.facade.getOne(id, q.slug, userId); + } + + @Get() + @GetAllIssuesSwagger() + getAll(@Query() query: IssueFiltersQueryDto, @GetUserId() userId: string) { + return this.facade.getAll(query, userId); + } + + @Patch(':id') + @UpdateIssueSwagger() + update( + @Param('id') id: string, + @Query() q: IssueQueryDto, + @Body() dto: UpdateIssueDto, + @GetUserId() userId: string, + ) { + return this.facade.update(id, q.slug, q.key, dto, userId); + } + + @Post(':id/move') + @MoveIssueSwagger() + @HttpCode(HttpStatus.OK) + move( + @Param('id') id: string, + @Query() q: IssueQueryDto, + @Body() dto: MoveIssueDto, + @GetUserId() userId: string, + ) { + return this.facade.move(id, q.slug, q.key, dto, userId); + } + + @Put(':id/assignee') + @AssignIssueSwagger() + assign( + @Param('id') id: string, + @Query() q: IssueQueryDto, + @Body() dto: AssignIssueDto, + @GetUserId() userId: string, + ) { + return this.facade.assign(id, q.slug, dto, userId); + } + + @Post(':id/restore') + @RestoreIssueSwagger() + restore(@Param('id') id: string, @Query() q: IssueQueryDto, @GetUserId() userId: string) { + return this.facade.restore(id, q.slug, userId); + } + + @Delete(':id') + @DeleteIssueSwagger() + delete(@Param('id') id: string, @Query() q: IssueQueryDto, @GetUserId() userId: string) { + return this.facade.delete(id, q.slug, userId); + } +} diff --git a/src/issue/application/controllers/issues/swagger.ts b/src/issue/application/controllers/issues/swagger.ts new file mode 100644 index 0000000..7657e8d --- /dev/null +++ b/src/issue/application/controllers/issues/swagger.ts @@ -0,0 +1,466 @@ +import { applyDecorators, SetMetadata } from '@nestjs/common'; +import { ApiBody, ApiOperation, ApiParam, ApiQuery, ApiResponse } from '@nestjs/swagger'; +import { ApiListQuery } from '@shared/decorators'; +import { + ApiConflict, + ApiForbidden, + ApiNotFound, + ApiUnauthorized, + ApiValidationError, +} from '@shared/error'; +import { ZOD_RESPONSE_TOKEN } from '@shared/interceptors'; +import { ActionResponse } from '@shared/schemas'; + +import { + AssignIssueDto, + CreateIssueDto, + CreateIssueResponse, + IssueResponse, + IssuesResponse, + MoveIssueDto, + UpdateIssueDto, +} from '../../dtos'; + +export const CreateIssueSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Создать новую задачу', + description: [ + 'Создаёт задачу на доске. По сути это карточка, которая появляется в одной из колонок.', + '', + '### Как работает', + '- Задача всегда создаётся в определённой колонке на доске (состоянии)', + '- Если колонка не указана — задача попадёт в крайнюю левую колонку (обычно «Бэклог»)', + '- Можно сразу назначить исполнителя, указать приоритет и тип задачи', + '', + '### Типы задач', + '- `TASK` — обычная задача', + '- `BUG` — баг (автоматически считается критичным)', + '- `EPIC` — эпик (крупная задача, может содержать подзадачи)', + '', + '### Приоритеты', + '- `LOW` — низкий (можно отложить)', + '- `MEDIUM` — средний (по умолчанию)', + '- `HIGH` — высокий (требует внимания)', + '- `CRITICAL` — критический (блокирует работу)', + '', + '### Иерархия', + '- Можно указать `parentId` — родительскую задачу', + '- Это позволяет строить деревья: Эпик → Задача → Подзадача', + '- Баг тоже может быть привязан к эпику или задаче', + '', + '### Метки', + '- Произвольный набор текстовых меток для категоризации', + '- Пример: `["backend", "auth", "high-priority"]`', + '- Метки создаются автоматически, если их ещё нет', + ].join('\n'), + }), + ApiBody({ + type: CreateIssueDto.Output, + description: 'Данные для создания задачи', + }), + ApiResponse({ + status: 201, + description: 'Задача успешно создана', + type: CreateIssueResponse.Output, + }), + ApiValidationError(), + ApiUnauthorized(), + ApiForbidden('Нет прав для создания задач на этой доске'), + ApiNotFound('Указанная колонка не найдена'), + ApiConflict('Задача с таким заголовком уже существует в этой колонке'), + + SetMetadata(ZOD_RESPONSE_TOKEN, CreateIssueResponse), + ); + +export const GetAllIssuesSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Получить список задач с фильтрацией', + description: [ + 'Возвращает задачи с учётом фильтров, сортировки и пагинации.', + '', + '### Основной сценарий — отображение доски', + '- Передайте `areaId` чтобы получить все задачи конкретной области', + '- Задачи будут сгруппированы по колонкам и отсортированы по позиции', + '- Это позволяет отрисовать Kanban-доску одним запросом', + '', + '### Фильтры', + '- `areaId` — показать задачи только с этой области (доски)', + '- `stateId` — показать задачи конкретного состояния (колонки)', + '- `assigneeId` — задачи конкретного исполнителя', + '- `priority` — фильтр по приоритету (LOW, MEDIUM, HIGH, CRITICAL)', + '- `type` — фильтр по типу (TASK, BUG, EPIC)', + '- `labels` — фильтр по меткам (через запятую). Задача должна иметь ВСЕ указанные метки (AND)', + '- `search` — полнотекстовый поиск по заголовку и описанию', + '', + '### Сортировка', + '- `sortBy` — поле для сортировки: `positionInColumn`, `createdAt`, `updatedAt`, `priority`', + '- `sortOrder` — направление: `ASC` или `DESC`', + '- По умолчанию сортировка по `positionInColumn ASC` — как на доске', + '', + '### Пагинация', + '- `limit` — количество задач на страницу (по умолчанию 50, максимум 200)', + '- `offset` — смещение для пагинации (по умолчанию 0)', + '', + '### Примеры использования', + '- Все задачи на доске: `GET /v1/issues?areaId=xxx`', + '- Мои задачи: `GET /v1/issues?assigneeId=yyy`', + '- Критические баги: `GET /v1/issues?type=BUG&priority=CRITICAL`', + '- Поиск: `GET /v1/issues?search=oauth+error`', + ].join('\n'), + }), + ApiQuery({ + name: 'areaId', + required: false, + type: 'string', + format: 'uuid', + description: 'ID доски — показать задачи только с этой доски', + example: 'd4e5f6a7-b8c9-0123-defa-234567890123', + }), + ApiQuery({ + name: 'stateId', + required: false, + type: 'string', + format: 'uuid', + description: 'ID колонки — показать задачи в конкретной колонке', + example: 'b8c9d0e1-f2a3-4567-bcde-678901234567', + }), + ApiQuery({ + name: 'assigneeId', + required: false, + type: 'string', + format: 'uuid', + description: 'ID исполнителя — только задачи этого пользователя', + example: 'b2c3d4e5-f6a7-8901-bcde-f12345678901', + }), + ApiQuery({ + name: 'priority', + required: false, + enum: ['LOW', 'MEDIUM', 'HIGH', 'CRITICAL'], + description: 'Фильтр по приоритету', + example: 'HIGH', + }), + ApiQuery({ + name: 'type', + required: false, + enum: ['TASK', 'BUG', 'EPIC'], + description: 'Фильтр по типу задачи', + example: 'BUG', + }), + ApiQuery({ + name: 'labels', + required: false, + type: 'string', + description: 'Метки через запятую (AND — задача должна иметь все)', + example: 'backend,auth', + }), + ApiListQuery({ + sortableFields: ['position'], + withSearch: true, + defaultSortField: 'position', + defaultSortOrder: 'asc', + }), + ApiResponse({ + status: 200, + description: 'Список задач получен', + type: IssuesResponse.Output, + }), + ApiUnauthorized(), + ApiNotFound('Доска не найдена'), + + SetMetadata(ZOD_RESPONSE_TOKEN, IssuesResponse), + ); + +export const GetOneIssueSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Получить детали задачи', + description: [ + 'Возвращает полную информацию о задаче со всеми связями.', + '', + '### Что включает ответ', + '- Основные поля: заголовок, описание, приоритет, тип', + '- Позиционирование: на какой доске и в какой колонке находится, позиция в колонке', + '- Исполнитель: кто назначен (с именем и ID)', + '- Иерархия: ID родительской задачи, если есть', + '- Метки: список всех меток задачи', + '- Временные метки: когда создана и обновлена', + '', + '### Когда использовать', + '- Открытие карточки задачи в модальном окне', + '- Получение полных данных перед редактированием', + '- Просмотр деталей задачи из уведомления', + ].join('\n'), + }), + ApiParam({ + name: 'id', + type: 'string', + format: 'uuid', + description: 'ID задачи', + example: '33333333-3333-3333-3333-333333333333', + }), + ApiResponse({ + status: 200, + description: 'Информация о задаче получена', + type: IssueResponse.Output, + }), + ApiNotFound('Задача не найдена или удалена'), + ApiUnauthorized(), + + SetMetadata(ZOD_RESPONSE_TOKEN, IssueResponse), + ); + +export const UpdateIssueSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Обновить метаданные задачи', + description: [ + 'Частичное обновление полей задачи. Передаются только те поля, которые нужно изменить.', + '', + '### Что можно обновить', + '- `title` — заголовок задачи', + '- `description` — описание (поддерживает Markdown)', + '- `priority` — приоритет (LOW, MEDIUM, HIGH, CRITICAL)', + '- `type` — тип (TASK, BUG, EPIC)', + '- `parentId` — привязка к родительской задаче', + '- `labels` — полный список меток (перезаписывает существующие)', + '', + '### Важные моменты', + '- Это НЕ перемещение задачи по доске — используйте `POST /move`', + '- Это НЕ назначение исполнителя — используйте `PUT /assignee`', + '- Метки передаются полным списком — старые метки заменяются новыми', + '- Можно передать `parentId: null` чтобы отвязать от родителя', + '', + '### Примеры', + '- Повысить приоритет: `{ "priority": "CRITICAL" }`', + '- Обновить метки: `{ "labels": ["backend", "auth", "security"] }`', + '- Сделать подзадачей: `{ "parentId": "99999999-9999-..." }`', + ].join('\n'), + }), + ApiParam({ + name: 'id', + type: 'string', + format: 'uuid', + description: 'ID задачи', + example: '33333333-3333-3333-3333-333333333333', + }), + ApiBody({ + type: UpdateIssueDto.Output, + description: 'Обновляемые поля (только те, что нужно изменить)', + }), + ApiResponse({ + status: 200, + description: 'Задача обновлена', + type: ActionResponse.Output, + }), + ApiValidationError(), + ApiNotFound('Задача не найдена или удалена'), + ApiUnauthorized(), + ApiForbidden('Нет прав для редактирования этой задачи'), + ApiConflict('Задача с таким заголовком уже существует'), + + SetMetadata(ZOD_RESPONSE_TOKEN, ActionResponse), + ); + +export const MoveIssueSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Переместить задачу по доске', + description: [ + 'Перемещает задачу в другую колонку и/или меняет её позицию внутри колонки.', + 'Это основная операция Kanban — перетаскивание карточек между колонками.', + '', + '### Как работает перемещение', + '- Задача переносится в целевую колонку (`targetColumnId`)', + '- Вставляется на указанную позицию (`position`, начиная с 0)', + '- Все остальные задачи в обеих колонках автоматически сдвигаются', + '', + '### Смена доски', + '- Если целевая колонка принадлежит другой доске — задача автоматически меняет доску', + '- Это происходит прозрачно, дополнительно указывать `boardId` не нужно', + '', + '### Позиционирование', + '- `position: 0` — задача становится первой в колонке (самая верхняя)', + '- `position: 3` — задача вставляется на 4-ю позицию', + '- Задачи ниже указанной позиции сдвигаются вниз (+1)', + '', + '### Типичные сценарии', + '- Взял в работу: переместить из «To Do» в «In Progress» на позицию 0', + '- Отправил на ревью: переместить из «In Progress» в «Code Review» в конец', + '- Drag-and-drop: перетащил карточку мышкой — фронтенд отправляет этот запрос', + '- Перемещение бага между досками: из «Bug Tracker» в «Development Board»', + '', + '### Ограничения', + '- Нельзя переместить удалённую задачу', + '- Целевая колонка должна существовать и быть активной (не архивной)', + '- В будущем: проверка WIP-лимитов колонки', + ].join('\n'), + }), + ApiParam({ + name: 'id', + type: 'string', + format: 'uuid', + description: 'ID перемещаемой задачи', + example: '33333333-3333-3333-3333-333333333333', + }), + ApiBody({ + type: MoveIssueDto.Output, + description: 'Целевая колонка и новая позиция', + }), + ApiResponse({ + status: 200, + description: + 'Задача успешно перемещена. Возвращает обновлённую задачу с новыми column и position', + type: ActionResponse.Output, + }), + ApiValidationError(), + ApiNotFound('Задача или целевая колонка не найдена'), + ApiUnauthorized(), + ApiForbidden('Нет прав для перемещения задач на этой доске'), + ApiConflict('Нарушен WIP-лимит целевой колонки'), + + SetMetadata(ZOD_RESPONSE_TOKEN, ActionResponse), + ); + +export const AssignIssueSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Назначить или снять исполнителя', + description: [ + 'Операция назначения ответственного за задачу.', + '', + '### Как работает', + '- Передайте `assigneeId` — пользователь будет назначен исполнителем', + '- Передайте `assigneeId: null` — исполнитель будет снят (задача станет «ничьей»)', + '- Предыдущий исполнитель автоматически снимается', + '', + '### Бизнес-логика', + '- При назначении создаётся запись в истории изменений (audit log)', + '- В будущем: отправка уведомления новому исполнителю', + '- В будущем: проверка, не перегружен ли исполнитель', + '', + '### Сценарии', + '- Самоназначение: разработчик берёт задачу из бэклога', + '- Переназначение: техлид передаёт задачу другому разработчику', + '- Снятие: задача возвращается в бэклог без исполнителя', + '', + '### Отличие от PATCH', + '- Это отдельный эндпоинт, а не часть общего PATCH', + '- Назначение — это бизнес-операция со своими правилами и сайд-эффектами', + '- Позволяет явно логировать смену ответственного', + ].join('\n'), + }), + ApiParam({ + name: 'id', + type: 'string', + format: 'uuid', + description: 'ID задачи', + example: '33333333-3333-3333-3333-333333333333', + }), + ApiBody({ + type: AssignIssueDto.Output, + description: 'ID нового исполнителя (или null чтобы снять)', + }), + ApiResponse({ + status: 200, + description: 'Исполнитель назначен или снят', + type: ActionResponse.Output, + }), + ApiValidationError(), + ApiNotFound('Задача не найдена или удалена'), + ApiUnauthorized(), + ApiForbidden('Нет прав для назначения исполнителей'), + + SetMetadata(ZOD_RESPONSE_TOKEN, ActionResponse), + ); + +export const DeleteIssueSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Мягкое удаление задачи', + description: [ + 'Мягкое удаление задачи — она перестаёт отображаться на доске, но данные сохраняются.', + '', + '### Как работает мягкое удаление', + '- Задача помечается как удалённая (`deletedAt` устанавливается в текущее время)', + '- Задача исчезает с доски и из всех списков', + '- Данные задачи сохраняются в базе', + '- Связи с метками сохраняются', + '', + '### Ограничения', + '- Нельзя удалить задачу, если у неё есть активные подзадачи', + '- Нужно сначала удалить или переместить все подзадачи', + '- Это защита от случайного удаления родительской задачи', + '', + '### Восстановление', + '- Удалённую задачу можно восстановить через `POST /restore`', + '- При восстановлении задача вернётся в ту же колонку', + '- Позиция будет восстановлена в конец колонки', + '', + '### Отличие от полного удаления', + '- Полное удаление (hard delete) не предусмотрено в API', + '- Это обеспечивает сохранность данных и возможность аудита', + ].join('\n'), + }), + ApiParam({ + name: 'id', + type: 'string', + format: 'uuid', + description: 'ID задачи', + example: '33333333-3333-3333-3333-333333333333', + }), + ApiResponse({ + status: 204, + description: 'Задача успешно удалена (мягкое удаление)', + }), + ApiNotFound('Задача не найдена или уже удалена'), + ApiUnauthorized(), + ApiForbidden('Нет прав для удаления этой задачи'), + ApiConflict('Нельзя удалить задачу с активными подзадачами'), + + SetMetadata(ZOD_RESPONSE_TOKEN, ActionResponse), + ); + +export const RestoreIssueSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Восстановить удалённую задачу', + description: [ + 'Восстанавливает мягко удалённую задачу.', + '', + '### Что восстанавливается', + '- Сама задача со всеми метаданными', + '- Связи с метками', + '- Привязка к исполнителю (если был)', + '- Родительская связь (если была)', + '', + '### Позиционирование', + '- Задача возвращается в ту же колонку, где была до удаления', + '- Позиция: в конец колонки (последней)', + '- Это позволяет быстро вернуть задачу без конфликтов позиционирования', + '', + '### Когда использовать', + '- Задачу удалили по ошибке', + '- Решили вернуть отложенную задачу в работу', + '- Восстановление архивных задач при пересмотре планов', + ].join('\n'), + }), + ApiParam({ + name: 'id', + type: 'string', + format: 'uuid', + description: 'ID удалённой задачи', + example: '33333333-3333-3333-3333-333333333333', + }), + ApiResponse({ + status: 200, + description: 'Задача восстановлена', + type: ActionResponse.Output, + }), + ApiNotFound('Удалённая задача не найдена'), + ApiUnauthorized(), + ApiForbidden('Нет прав для восстановления задач'), + + SetMetadata(ZOD_RESPONSE_TOKEN, ActionResponse), + ); diff --git a/src/issue/application/dtos/index.ts b/src/issue/application/dtos/index.ts new file mode 100644 index 0000000..4a3cba9 --- /dev/null +++ b/src/issue/application/dtos/index.ts @@ -0,0 +1 @@ +export * from './issue.dto'; diff --git a/src/issue/application/dtos/issue.dto.ts b/src/issue/application/dtos/issue.dto.ts new file mode 100644 index 0000000..de129af --- /dev/null +++ b/src/issue/application/dtos/issue.dto.ts @@ -0,0 +1,234 @@ +import { createZodDto } from 'nestjs-zod'; +import { z } from 'zod/v4'; + +import { + ActionResponseSchema, + createSortingSchema, + PaginationBaseSchema, +} from '../../../shared/schemas'; +import { ISSUE_TYPE_LIST, PRIORITY_LIST } from '../../domain/entities'; + +export const PriorityEnumSchema = z + .enum(PRIORITY_LIST) + .default('medium') + .describe('Приоритет задачи: low, medium, high, critical'); + +export const IssueTypeEnumSchema = z + .enum(ISSUE_TYPE_LIST) + .default('task') + .describe('Тип задачи: task, bug, epic'); + +const AssigneeInfoSchema = z.object({ + id: z.string().describe('ID пользователя'), + name: z.string().describe('Отображаемое имя'), + email: z.string().email().optional().describe('Email пользователя'), + avatarUrl: z.string().url().nullable().optional().describe('URL аватара'), +}); + +const ParentInfoSchema = z.object({ + id: z.string().nullable().optional().describe('ID родительской задачи (для подзадач)'), + title: z + .string() + .nullable() + .optional() + .describe('Заголовок родительской задачи (денормализованное поле для отображения)'), +}); + +export const IssueSchema = z.object({ + id: z.string().min(1, 'ID не может быть пустым').describe('Уникальный идентификатор задачи'), + title: z + .string() + .min(1, 'Заголовок обязателен') + .max(255, 'Заголовок не должен превышать 255 символов') + .describe('Заголовок задачи (например: "Добавить экспорт в PDF")'), + description: z + .string() + .nullable() + .optional() + .describe('Markdown-описание задачи, детали реализации'), + descriptionHtml: z + .string() + .nullable() + .optional() + .describe('Markdown-описание задачи, детали реализации'), + priority: PriorityEnumSchema.describe('Приоритет задачи'), + type: IssueTypeEnumSchema.describe('Тип задачи'), + areaId: z + .string() + .min(1, 'ID области обязателен') + .describe('ID области, к которой привязана задача'), + stateId: z + .string() + .nullable() + .optional() + .describe('ID текущего состояния (колонки). Null — задача без состояния'), + position: z + .number() + .int('Позиция должна быть целым числом') + .min(0, 'Позиция не может быть отрицательной') + .default(0) + .describe('Порядковый номер задачи внутри колонки (0 — первая/верхняя)'), + assigneeId: z + .string() + .nullable() + .optional() + .describe('ID текущего исполнителя. Null — задача не назначена'), + assignee: AssigneeInfoSchema.nullable().describe( + 'Текущий исполнитель задачи. Null — задача не назначена', + ), + reporterId: z.string().nullable().optional().describe('ID автора задачи. Null — не указан'), + reporter: AssigneeInfoSchema.nullable() + .optional() + .describe('Автор задачи (кто создал). Null — не указан'), + parentId: z + .string() + .nullable() + .optional() + .describe('ID родительской задачи (для подзадач). Null — задача верхнего уровня'), + parent: ParentInfoSchema.nullable() + .optional() + .describe('Родительская задача. Null — задача верхнего уровня'), + labels: z + .array(z.string().max(50, 'Метка не должна превышать 50 символов')) + .default([]) + .describe('Список текстовых меток для категоризации (например: ["backend", "auth"])'), + storyPoints: z + .number() + .int('Story points должны быть целым числом') + .min(0, 'Story points не могут быть отрицательными') + .max(10000, 'Story points не могут быть больше 1000') + .nullable() + .optional() + .describe('Оценка сложности в story points (для Scrum)'), + dueDate: z + .string() + .datetime({ offset: true }) + .nullable() + .optional() + .describe('Крайний срок выполнения (ISO 8601). Null — без срока'), + createdAt: z + .string() + .datetime({ offset: true }) + .describe('Дата и время создания задачи (ISO 8601 с таймзоной)'), + updatedAt: z + .string() + .datetime({ offset: true }) + .describe('Дата и время последнего обновления задачи'), + createdBy: z.string().nullable().optional().describe('ID пользователя, создавшего задачу'), + deletedAt: z + .string() + .datetime({ offset: true }) + .nullable() + .optional() + .describe('Дата мягкого удаления (null — задача активна)'), +}); + +export const CreateIssueSchema = IssueSchema.omit({ + id: true, + assignee: true, + reporter: true, + parent: true, + createdAt: true, + updatedAt: true, + createdBy: true, + deletedAt: true, +}) + .partial({ + description: true, + descriptionHtml: true, + priority: true, + type: true, + stateId: true, + assigneeId: true, + reporterId: true, + parentId: true, + labels: true, + storyPoints: true, + dueDate: true, + position: true, + }) + .describe('Схема для создания новой задачи'); + +export const CreateIssueResponseSchema = ActionResponseSchema.extend({ + id: z.string().describe('Уникальный идентификатор созданной задачи'), +}); + +export const UpdateIssueSchema = CreateIssueSchema.partial() + .refine((data) => Object.keys(data).length > 0, { + error: 'Необходимо передать хотя бы одно поле для обновления', + abort: true, + }) + .describe('Схема для обновления задачи'); + +export const MoveIssueSchema = z + .object({ + targetAreaId: z + .string() + .optional() + .describe( + 'Целевая область (если перемещаем между областями). Если не указана — остаётся текущая', + ), + targetStateId: z + .string() + .nullable() + .optional() + .describe('Целевое состояние (колонка). Null — убрать из состояния'), + position: z + .number() + .int('Позиция должна быть целым числом') + .min(0, 'Позиция не может быть отрицательной') + .describe('Новая позиция в колонке (0 — первая/верхняя)'), + }) + .describe('Схема для перемещения задачи по доске или между областями'); + +export const AssignIssueSchema = z + .object({ + assigneeId: z + .string() + .nullable() + .describe('ID нового исполнителя. Null — снять текущего исполнителя'), + }) + .describe('Схема для назначения/снятия исполнителя'); + +export class CreateIssueDto extends createZodDto(CreateIssueSchema) {} +export class UpdateIssueDto extends createZodDto(UpdateIssueSchema) {} +export class CreateIssueResponse extends createZodDto(CreateIssueResponseSchema) {} + +export class MoveIssueDto extends createZodDto(MoveIssueSchema) {} +export class AssignIssueDto extends createZodDto(AssignIssueSchema) {} + +export const IssueQuerySchema = z + .object({ + slug: z.string().describe('Слаг проекта'), + key: z.string().describe('Слаг области'), + }) + + .describe('Обязательные Query параметры для управления задачами'); + +export const IssueFiltersQuerySchema = IssueQuerySchema.extend({ + stateId: z.string().optional().describe('Фильтр по состоянию (колонке)'), + assigneeId: z.string().optional().describe('Фильтр по исполнителю'), + reporterId: z.string().optional().describe('Фильтр по автору'), + priority: PriorityEnumSchema.optional().describe('Фильтр по приоритету'), + type: IssueTypeEnumSchema.optional().describe('Фильтр по типу задачи'), + parentId: z + .string() + .nullable() + .optional() + .describe('Фильтр по родителю (null — только задачи верхнего уровня)'), + labels: z + .string() + .optional() + .describe('Метки через запятую (AND — задача должна иметь все указанные)'), +}) + .extend(PaginationBaseSchema.shape) + .extend(createSortingSchema(['position', 'createdAt', 'priority']).shape) + .describe('Query параметры для получения списка задач с фильтрацией'); + +export const IssuesSchema = z.array(IssueSchema); + +export class IssueQueryDto extends createZodDto(IssueQuerySchema) {} +export class IssueFiltersQueryDto extends createZodDto(IssueFiltersQuerySchema) {} + +export class IssueResponse extends createZodDto(IssueSchema) {} +export class IssuesResponse extends createZodDto(IssuesSchema) {} diff --git a/src/issue/application/issue.facade.ts b/src/issue/application/issue.facade.ts new file mode 100644 index 0000000..8d107ed --- /dev/null +++ b/src/issue/application/issue.facade.ts @@ -0,0 +1,67 @@ +import { Injectable } from '@nestjs/common'; + +import { + AssignIssueDto, + CreateIssueDto, + IssueQueryDto, + MoveIssueDto, + UpdateIssueDto, +} from './dtos'; +import { + AssignIssueUseCase, + CreateIssueUseCase, + DeleteIssueUseCase, + FindAllIssueQuery, + FindOneIssueQuery, + MoveIssueUseCase, + RestoreIssueUseCase, + UpdateIssueUseCase, +} from './use-cases'; + +@Injectable() +export class IssueFacade { + constructor( + private readonly createIssueUC: CreateIssueUseCase, + private readonly updateIssueUC: UpdateIssueUseCase, + private readonly deleteIssueUC: DeleteIssueUseCase, + private readonly assignIssueUC: AssignIssueUseCase, + private readonly getOneIssueQ: FindOneIssueQuery, + private readonly getAllIssueQ: FindAllIssueQuery, + private readonly moveIssueUC: MoveIssueUseCase, + private readonly restoreIssueUC: RestoreIssueUseCase, + ) {} + + public create = async (dto: CreateIssueDto, slug: string, key: string, userId: string) => + this.createIssueUC.execute(dto, slug, key, userId); + + public getOne = async (id: string, slug: string, userId: string) => + this.getOneIssueQ.execute(id, slug, userId); + + public getAll = async (query: IssueQueryDto, userId: string) => + this.getAllIssueQ.execute(query, userId); + + public update = async ( + id: string, + slug: string, + key: string, + dto: UpdateIssueDto, + userId: string, + ) => this.updateIssueUC.execute(id, slug, key, dto, userId); + + public move = async ( + id: string, + slug: string, + key: string, + dto: MoveIssueDto, + userId: string, + ) => this.moveIssueUC.execute(id, slug, key, dto, userId); + + public assign = async (id: string, slug: string, dto: AssignIssueDto, userId: string) => + this.assignIssueUC.execute(id, slug, dto, userId); + + public delete = async (id: string, slug: string, userId: string) => + this.deleteIssueUC.execute(id, slug, userId); + + public restore = async (id: string, slug: string, userId: string) => + this.restoreIssueUC.execute(id, slug, userId); +} diff --git a/src/issue/application/mappers/index.ts b/src/issue/application/mappers/index.ts new file mode 100644 index 0000000..53a5178 --- /dev/null +++ b/src/issue/application/mappers/index.ts @@ -0,0 +1 @@ +export * from './issue.mapper'; diff --git a/src/issue/application/mappers/issue.mapper.ts b/src/issue/application/mappers/issue.mapper.ts new file mode 100644 index 0000000..0a6dfd2 --- /dev/null +++ b/src/issue/application/mappers/issue.mapper.ts @@ -0,0 +1,29 @@ +import type { IssueResponse } from '../dtos'; +import type { Issue } from '@core/issue/domain/entities'; + +export class IssueMapper { + public static toResponseDto(issue: Issue): IssueResponse { + return { + id: issue.id, + title: issue.title, + description: issue.description ?? null, + priority: issue.priority, + type: issue.type, + areaId: issue.areaId, + stateId: issue.stateId ?? null, + position: issue.position ?? 0, + assignee: issue.assignee ?? null, + reporter: issue.reporter ?? null, + parent: issue.parent ?? null, + //TODO: labels + labels: [], + storyPoints: issue.storyPoints ?? null, + dueDate: issue.dueDate && new Date(issue.dueDate).toISOString(), + createdAt: new Date(issue.createdAt).toISOString(), + updatedAt: new Date(issue.updatedAt).toISOString(), + //TODO: created by + createdBy: null, + deletedAt: issue.deletedAt && new Date(issue.deletedAt).toISOString(), + }; + } +} diff --git a/src/issue/application/use-cases/base/assign.use-case.ts b/src/issue/application/use-cases/base/assign.use-case.ts new file mode 100644 index 0000000..e06efd6 --- /dev/null +++ b/src/issue/application/use-cases/base/assign.use-case.ts @@ -0,0 +1,77 @@ +import { IssueErrorCodes, IssueErrorMessages } from '@core/issue/domain/errors'; +import { IIssueRepository } from '@core/issue/domain/repositories'; +import { FindProjectMemberQuery } from '@core/project/application/use-cases'; +import { MemberErrorCodes, MemberErrorMessages } from '@core/project/domain/errors'; +import { ProjectAccessPolicy } from '@core/project/domain/policy'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { BaseException } from '@shared/error'; + +import { AssignIssueDto } from '../../dtos'; + +@Injectable() +export class AssignIssueUseCase { + constructor( + @Inject('IIssueRepository') + private readonly issueRepo: IIssueRepository, + private readonly getProjectMember: FindProjectMemberQuery, + private readonly projectPolicy: ProjectAccessPolicy, + ) {} + + async execute(id: string, slug: string, dto: AssignIssueDto, userId: string) { + try { + const { project } = await this.projectPolicy.ensureProjectAccess(slug, userId, [ + 'owner', + 'admin', + ]); + + const issue = await this.issueRepo.findOne(id, userId); + + if (!issue) { + throw new BaseException( + { + code: IssueErrorCodes.NOT_FOUND, + message: IssueErrorMessages[IssueErrorCodes.NOT_FOUND], + }, + HttpStatus.NOT_FOUND, + ); + } + + if (dto.assigneeId) { + const member = await this.getProjectMember.execute(project.id, dto.assigneeId); + + if (!member) { + throw new BaseException( + { + code: MemberErrorCodes.NOT_FOUND, + message: MemberErrorMessages[MemberErrorCodes.NOT_FOUND], + }, + HttpStatus.NOT_FOUND, + ); + } + } + + const result = await this.issueRepo.update(id, { assigneeId: dto.assigneeId }, userId); + + return { + success: result, + message: result + ? dto.assigneeId + ? 'Исполнитель успешно назначен' + : 'Исполнитель успешно снят' + : 'Не удалось назначить исполнителя', + }; + } catch (e) { + if (e instanceof BaseException) { + throw e; + } + + throw new BaseException( + { + code: IssueErrorCodes.ASSIGN_FAILED, + message: IssueErrorMessages[IssueErrorCodes.ASSIGN_FAILED], + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } +} diff --git a/src/issue/application/use-cases/base/create.use-case.ts b/src/issue/application/use-cases/base/create.use-case.ts new file mode 100644 index 0000000..9ff89c1 --- /dev/null +++ b/src/issue/application/use-cases/base/create.use-case.ts @@ -0,0 +1,117 @@ +import { GetAreaQuery, GetStateQuery } from '@core/area/application/use-cases'; +import { ISSUE_TYPE, PRIORITY } from '@core/issue/domain/entities'; +import { IssueErrorCodes, IssueErrorMessages } from '@core/issue/domain/errors'; +import { IIssueRepository } from '@core/issue/domain/repositories'; +import { FindProjectMemberQuery } from '@core/project/application/use-cases'; +import { MemberErrorCodes, MemberErrorMessages } from '@core/project/domain/errors'; +import { ProjectAccessPolicy } from '@core/project/domain/policy'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { BaseException } from '@shared/error'; + +import { CreateIssueDto } from '../../dtos'; + +@Injectable() +export class CreateIssueUseCase { + constructor( + @Inject('IIssueRepository') + private readonly issueRepo: IIssueRepository, + private readonly getArea: GetAreaQuery, + private readonly getState: GetStateQuery, + private readonly getProjectMember: FindProjectMemberQuery, + private readonly projectPolicy: ProjectAccessPolicy, + ) {} + + async execute(dto: CreateIssueDto, slug: string, key: string, userId: string) { + try { + const { project } = await this.projectPolicy.ensureProjectAccess(slug, userId, [ + 'owner', + 'admin', + ]); + + await this.validateContext(dto, userId, project.id, key); + + const data: CreateIssueDto = { + ...dto, + type: dto.type ?? ISSUE_TYPE.TASK, + priority: dto.priority ?? PRIORITY.MEDIUM, + }; + + const result = await this.issueRepo.create(data, userId); + + return { + success: true, + message: 'Задача успешно создана', + id: result.id, + }; + } catch (e) { + if (e instanceof BaseException) { + throw e; + } + + throw new BaseException( + { + code: IssueErrorCodes.CREATE_FAILED, + message: IssueErrorMessages[IssueErrorCodes.CREATE_FAILED], + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + + private async validateContext( + dto: CreateIssueDto, + userId: string, + projectId: string, + key: string, + ) { + if (dto.stateId) { + await this.getState.execute(key, dto.stateId, userId); + } else { + await this.getArea.execute({ key }, userId); + } + + if (dto.assigneeId) { + const projectMember = await this.getProjectMember.execute(projectId, dto.assigneeId); + + if (!projectMember) { + throw new BaseException( + { + code: MemberErrorCodes.NOT_FOUND, + message: MemberErrorMessages[MemberErrorCodes.NOT_FOUND], + details: [{ target: 'assignee' }], + }, + HttpStatus.NOT_FOUND, + ); + } + } + + if (dto.reporterId) { + const projectMember = await this.getProjectMember.execute(projectId, dto.reporterId); + + if (!projectMember) { + throw new BaseException( + { + code: MemberErrorCodes.NOT_FOUND, + message: MemberErrorMessages[MemberErrorCodes.NOT_FOUND], + details: [{ target: 'reporter' }], + }, + HttpStatus.NOT_FOUND, + ); + } + } + + if (dto.parentId) { + const parent = await this.issueRepo.findOne(dto.parentId, userId); + + if (!parent) { + throw new BaseException( + { + code: IssueErrorCodes.PARENT_NOT_FOUND, + message: IssueErrorMessages[IssueErrorCodes.PARENT_NOT_FOUND], + }, + HttpStatus.NOT_FOUND, + ); + } + } + } +} diff --git a/src/issue/application/use-cases/base/delete.use-case.ts b/src/issue/application/use-cases/base/delete.use-case.ts new file mode 100644 index 0000000..31938fe --- /dev/null +++ b/src/issue/application/use-cases/base/delete.use-case.ts @@ -0,0 +1,53 @@ +import { IssueErrorCodes, IssueErrorMessages } from '@core/issue/domain/errors'; +import { IIssueRepository } from '@core/issue/domain/repositories'; +import { ProjectAccessPolicy } from '@core/project/domain/policy'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { BaseException } from '@shared/error'; + +@Injectable() +export class DeleteIssueUseCase { + constructor( + @Inject('IIssueRepository') + private readonly issueRepo: IIssueRepository, + private readonly projectPolicy: ProjectAccessPolicy, + ) {} + + async execute(id: string, slug: string, userId: string) { + try { + await this.projectPolicy.ensureProjectAccess(slug, userId, ['owner', 'admin']); + + const issue = await this.issueRepo.findOne(id, userId); + + if (!issue) { + throw new BaseException( + { + code: IssueErrorCodes.NOT_FOUND, + message: IssueErrorMessages[IssueErrorCodes.NOT_FOUND], + }, + HttpStatus.NOT_FOUND, + ); + } + + const result = await this.issueRepo.delete(id, userId); + + return { + success: result, + message: result + ? 'Задача успешно удалена' + : 'Не удалось удалить задачу: запись не найдена или уже удалена', + }; + } catch (e) { + if (e instanceof BaseException) { + throw e; + } + + throw new BaseException( + { + code: IssueErrorCodes.DELETE_FAILED, + message: IssueErrorMessages[IssueErrorCodes.DELETE_FAILED], + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } +} diff --git a/src/issue/application/use-cases/base/find-all.query.ts b/src/issue/application/use-cases/base/find-all.query.ts new file mode 100644 index 0000000..0d340ab --- /dev/null +++ b/src/issue/application/use-cases/base/find-all.query.ts @@ -0,0 +1,29 @@ +import { IIssueRepository } from '@core/issue/domain/repositories'; +import { CheckVisibilityOrThrowQuery } from '@core/project/application/use-cases'; +import { ProjectAccessPolicy } from '@core/project/domain/policy'; +import { Inject, Injectable } from '@nestjs/common'; + +import { IssueQueryDto } from '../../dtos'; +import { IssueMapper } from '../../mappers'; + +@Injectable() +export class FindAllIssueQuery { + constructor( + @Inject('IIssueRepository') + private readonly issueRepo: IIssueRepository, + private readonly projectVisibility: CheckVisibilityOrThrowQuery, + private readonly projectPolicy: ProjectAccessPolicy, + ) {} + + async execute(query: IssueQueryDto, userId: string) { + const visibility = await this.projectVisibility.execute(query.slug); + + if (visibility === 'private') { + await this.projectPolicy.ensureProjectAccess(query.slug, userId); + } + + const issues = await this.issueRepo.find(query); + + return issues.map((issue) => IssueMapper.toResponseDto(issue)); + } +} diff --git a/src/issue/application/use-cases/base/find-one.query.ts b/src/issue/application/use-cases/base/find-one.query.ts new file mode 100644 index 0000000..ed749a9 --- /dev/null +++ b/src/issue/application/use-cases/base/find-one.query.ts @@ -0,0 +1,40 @@ +import { IssueErrorCodes, IssueErrorMessages } from '@core/issue/domain/errors'; +import { IIssueRepository } from '@core/issue/domain/repositories'; +import { CheckVisibilityOrThrowQuery } from '@core/project/application/use-cases'; +import { ProjectAccessPolicy } from '@core/project/domain/policy'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { BaseException } from '@shared/error'; + +import { IssueMapper } from '../../mappers'; + +@Injectable() +export class FindOneIssueQuery { + constructor( + @Inject('IIssueRepository') + private readonly issueRepo: IIssueRepository, + private readonly projectVisibility: CheckVisibilityOrThrowQuery, + private readonly projectPolicy: ProjectAccessPolicy, + ) {} + + async execute(id: string, slug: string, userId: string) { + const visibility = await this.projectVisibility.execute(slug); + + if (visibility === 'private') { + await this.projectPolicy.ensureProjectAccess(slug, userId); + } + + const issue = await this.issueRepo.findOne(id, userId); + + if (!issue) { + throw new BaseException( + { + code: IssueErrorCodes.NOT_FOUND, + message: IssueErrorMessages[IssueErrorCodes.NOT_FOUND], + }, + HttpStatus.NOT_FOUND, + ); + } + + return IssueMapper.toResponseDto(issue); + } +} diff --git a/src/issue/application/use-cases/base/move.use-case.ts b/src/issue/application/use-cases/base/move.use-case.ts new file mode 100644 index 0000000..2de6f05 --- /dev/null +++ b/src/issue/application/use-cases/base/move.use-case.ts @@ -0,0 +1,66 @@ +import { GetAreaQuery, GetStateQuery } from '@core/area/application/use-cases'; +import { IssueErrorCodes, IssueErrorMessages } from '@core/issue/domain/errors'; +import { IIssueRepository } from '@core/issue/domain/repositories'; +import { ProjectAccessPolicy } from '@core/project/domain/policy'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { BaseException } from '@shared/error'; + +import { MoveIssueDto } from '../../dtos'; + +@Injectable() +export class MoveIssueUseCase { + constructor( + @Inject('IIssueRepository') + private readonly issueRepo: IIssueRepository, + private readonly getArea: GetAreaQuery, + private readonly getState: GetStateQuery, + private readonly projectPolicy: ProjectAccessPolicy, + ) {} + + async execute(id: string, slug: string, key: string, dto: MoveIssueDto, userId: string) { + try { + await this.projectPolicy.ensureProjectAccess(slug, userId, ['owner', 'admin']); + + const issue = await this.issueRepo.findOne(id, userId); + + if (!issue) { + throw new BaseException( + { + code: IssueErrorCodes.NOT_FOUND, + message: IssueErrorMessages[IssueErrorCodes.NOT_FOUND], + }, + HttpStatus.NOT_FOUND, + ); + } + await this.validateContext(dto, key, userId); + + const result = await this.issueRepo.update(id, dto, userId); + + return { + success: result, + message: result ? 'Задача успешно перемещена' : 'Не удалось переместить задачу', + }; + } catch (e) { + if (e instanceof BaseException) { + throw e; + } + + throw new BaseException( + { + code: IssueErrorCodes.MOVE_FAILED, + message: IssueErrorMessages[IssueErrorCodes.MOVE_FAILED], + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + + private async validateContext(dto: MoveIssueDto, key: string, userId: string) { + if (dto.targetAreaId) { + await this.getArea.execute({ key }, userId); + } + if (dto.targetStateId) { + await this.getState.execute(key, dto.targetStateId, userId); + } + } +} diff --git a/src/issue/application/use-cases/base/restore.use-case.ts b/src/issue/application/use-cases/base/restore.use-case.ts new file mode 100644 index 0000000..56110a7 --- /dev/null +++ b/src/issue/application/use-cases/base/restore.use-case.ts @@ -0,0 +1,49 @@ +import { IssueErrorCodes, IssueErrorMessages } from '@core/issue/domain/errors'; +import { IIssueRepository } from '@core/issue/domain/repositories'; +import { ProjectAccessPolicy } from '@core/project/domain/policy'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { BaseException } from '@shared/error'; + +@Injectable() +export class RestoreIssueUseCase { + constructor( + @Inject('IIssueRepository') + private readonly issueRepo: IIssueRepository, + private readonly projectPolicy: ProjectAccessPolicy, + ) {} + + async execute(id: string, slug: string, userId: string) { + try { + await this.projectPolicy.ensureProjectAccess(slug, userId, ['owner', 'admin']); + + const result = await this.issueRepo.restore(id, userId); + + if (!result) { + throw new BaseException( + { + code: IssueErrorCodes.NOT_FOUND, + message: IssueErrorMessages[IssueErrorCodes.NOT_FOUND], + }, + HttpStatus.NOT_FOUND, + ); + } + + return { + success: true, + message: 'Задача успешно восстановлена', + }; + } catch (e) { + if (e instanceof BaseException) { + throw e; + } + + throw new BaseException( + { + code: IssueErrorCodes.RESTORE_FAILED, + message: IssueErrorMessages[IssueErrorCodes.RESTORE_FAILED], + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } +} diff --git a/src/issue/application/use-cases/base/update.use-case.ts b/src/issue/application/use-cases/base/update.use-case.ts new file mode 100644 index 0000000..2625f59 --- /dev/null +++ b/src/issue/application/use-cases/base/update.use-case.ts @@ -0,0 +1,131 @@ +import { GetStateQuery } from '@core/area/application/use-cases'; +import { IssueErrorCodes, IssueErrorMessages } from '@core/issue/domain/errors'; +import { IIssueRepository } from '@core/issue/domain/repositories'; +import { FindProjectMemberQuery } from '@core/project/application/use-cases'; +import { MemberErrorCodes, MemberErrorMessages } from '@core/project/domain/errors'; +import { ProjectAccessPolicy } from '@core/project/domain/policy'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { BaseException } from '@shared/error'; + +import { UpdateIssueDto } from '../../dtos'; + +@Injectable() +export class UpdateIssueUseCase { + constructor( + @Inject('IIssueRepository') + private readonly issueRepo: IIssueRepository, + private readonly getState: GetStateQuery, + private readonly getProjectMember: FindProjectMemberQuery, + private readonly projectPolicy: ProjectAccessPolicy, + ) {} + + async execute(id: string, slug: string, key: string, dto: UpdateIssueDto, userId: string) { + try { + const { project } = await this.projectPolicy.ensureProjectAccess(slug, userId, [ + 'owner', + 'admin', + ]); + + const issue = await this.issueRepo.findOne(id, userId); + + if (!issue) { + throw new BaseException( + { + code: IssueErrorCodes.NOT_FOUND, + message: IssueErrorMessages[IssueErrorCodes.NOT_FOUND], + }, + HttpStatus.NOT_FOUND, + ); + } + + await this.validateContext(id, dto, project.id, key, userId); + + const result = await this.issueRepo.update(id, dto, userId); + + return { + success: result, + message: result + ? 'Задача успешно обновлена' + : 'Не удалось обновить задачу: запись не найдена', + }; + } catch (e) { + if (e instanceof BaseException) { + throw e; + } + + throw new BaseException( + { + code: IssueErrorCodes.UPDATE_FAILED, + message: IssueErrorMessages[IssueErrorCodes.UPDATE_FAILED], + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + + private async validateContext( + id: string, + dto: UpdateIssueDto, + projectId: string, + key: string, + userId: string, + ) { + if (dto.assigneeId) { + const member = await this.getProjectMember.execute(projectId, dto.assigneeId); + + if (!member) { + throw new BaseException( + { + code: MemberErrorCodes.NOT_FOUND, + message: MemberErrorMessages[MemberErrorCodes.NOT_FOUND], + details: [{ target: 'assignee' }], + }, + HttpStatus.NOT_FOUND, + ); + } + } + + if (dto.reporterId) { + const member = await this.getProjectMember.execute(projectId, dto.reporterId); + + if (!member) { + throw new BaseException( + { + code: MemberErrorCodes.NOT_FOUND, + message: MemberErrorMessages[MemberErrorCodes.NOT_FOUND], + details: [{ target: 'reporter' }], + }, + HttpStatus.NOT_FOUND, + ); + } + } + + if (dto.stateId) { + await this.getState.execute(key, dto.stateId, userId); + } + + if (dto.parentId) { + if (dto.parentId === id) { + throw new BaseException( + { + code: IssueErrorCodes.SELF_PARENT, + message: IssueErrorMessages[IssueErrorCodes.SELF_PARENT], + }, + HttpStatus.BAD_REQUEST, + ); + } + + const parent = await this.issueRepo.findOne(dto.parentId, userId); + + if (!parent) { + throw new BaseException( + { + code: IssueErrorCodes.PARENT_NOT_FOUND, + message: IssueErrorMessages[IssueErrorCodes.PARENT_NOT_FOUND], + }, + HttpStatus.NOT_FOUND, + ); + } + } + } +} diff --git a/src/issue/application/use-cases/index.ts b/src/issue/application/use-cases/index.ts new file mode 100644 index 0000000..2e82199 --- /dev/null +++ b/src/issue/application/use-cases/index.ts @@ -0,0 +1,28 @@ +import { AssignIssueUseCase } from './base/assign.use-case'; +import { CreateIssueUseCase } from './base/create.use-case'; +import { DeleteIssueUseCase } from './base/delete.use-case'; +import { FindAllIssueQuery } from './base/find-all.query'; +import { FindOneIssueQuery } from './base/find-one.query'; +import { MoveIssueUseCase } from './base/move.use-case'; +import { RestoreIssueUseCase } from './base/restore.use-case'; +import { UpdateIssueUseCase } from './base/update.use-case'; + +export * from './base/assign.use-case'; +export * from './base/create.use-case'; +export * from './base/delete.use-case'; +export * from './base/find-all.query'; +export * from './base/find-one.query'; +export * from './base/move.use-case'; +export * from './base/restore.use-case'; +export * from './base/update.use-case'; + +export const USE_CASES = [ + CreateIssueUseCase, + UpdateIssueUseCase, + DeleteIssueUseCase, + AssignIssueUseCase, + MoveIssueUseCase, + RestoreIssueUseCase, + FindOneIssueQuery, + FindAllIssueQuery, +]; diff --git a/src/issue/domain/entities/enum.ts b/src/issue/domain/entities/enum.ts new file mode 100644 index 0000000..cb6be03 --- /dev/null +++ b/src/issue/domain/entities/enum.ts @@ -0,0 +1,20 @@ +export const PRIORITY = { + CRITICAL: 'critical', + LOW: 'low', + MEDIUM: 'medium', + HIGH: 'high', +} as const; + +export type PriorityType = (typeof PRIORITY)[keyof typeof PRIORITY]; + +export const PRIORITY_LIST = Object.values(PRIORITY); + +export const ISSUE_TYPE = { + BUG: 'bug', + TASK: 'task', + EPIC: 'epic', +} as const; + +export type IssueType = (typeof ISSUE_TYPE)[keyof typeof ISSUE_TYPE]; + +export const ISSUE_TYPE_LIST = Object.values(ISSUE_TYPE); diff --git a/src/issue/domain/entities/index.ts b/src/issue/domain/entities/index.ts new file mode 100644 index 0000000..411c55e --- /dev/null +++ b/src/issue/domain/entities/index.ts @@ -0,0 +1,2 @@ +export * from './issue.domain'; +export * from './enum'; diff --git a/src/issue/domain/entities/issue.domain.ts b/src/issue/domain/entities/issue.domain.ts new file mode 100644 index 0000000..177db16 --- /dev/null +++ b/src/issue/domain/entities/issue.domain.ts @@ -0,0 +1,47 @@ +import type { IssueType, PriorityType } from './enum'; + +export type IssueUser = { + readonly id: string; + readonly name: string; + readonly email: string; + readonly avatarUrl: string | null; +}; + +export type Issue = { + readonly id: string; + readonly title: string; + readonly description: string | null; + readonly descriptionHtml: string | null; + readonly stateId: string | null; + readonly areaId: string; + readonly priority: PriorityType; + readonly type: IssueType; + readonly position: number; + readonly storyPoints: number | null; + readonly dueDate: string | null; + readonly createdAt: string; + readonly updatedAt: string; + readonly deletedAt: string | null; + + readonly assignee: IssueUser | null; + readonly reporter: IssueUser; + readonly parent: { id: string; title: string } | null; +}; + +export type NewIssue = { + readonly title: string; + readonly areaId: string; + + readonly description?: string | null; + readonly descriptionHtml?: string | null; + readonly stateId?: string | null; + readonly priority?: PriorityType; + readonly type?: IssueType; + readonly position?: number; + readonly storyPoints?: number | null; + readonly dueDate?: string | null; + + readonly reporterId?: string | null; + readonly assigneeId?: string | null; + readonly parentId?: string | null; +}; diff --git a/src/issue/domain/errors/index.ts b/src/issue/domain/errors/index.ts new file mode 100644 index 0000000..fcbf538 --- /dev/null +++ b/src/issue/domain/errors/index.ts @@ -0,0 +1 @@ +export * from './issue.errors'; diff --git a/src/issue/domain/errors/issue.errors.ts b/src/issue/domain/errors/issue.errors.ts new file mode 100644 index 0000000..2db2ba5 --- /dev/null +++ b/src/issue/domain/errors/issue.errors.ts @@ -0,0 +1,66 @@ +export const IssueErrorCodes = { + // 404 + NOT_FOUND: 'ISSUE.NOT_FOUND', + + // 409 — Conflict + TITLE_DUPLICATE: 'ISSUE.TITLE_DUPLICATE', + ALREADY_DELETED: 'ISSUE.ALREADY_DELETED', + NOT_DELETED: 'ISSUE.NOT_DELETED', + + // 400 — Bad Request + TITLE_REQUIRED: 'ISSUE.TITLE_REQUIRED', + TITLE_TOO_LONG: 'ISSUE.TITLE_TOO_LONG', + AREA_REQUIRED: 'ISSUE.AREA_REQUIRED', + INVALID_PRIORITY: 'ISSUE.INVALID_PRIORITY', + INVALID_TYPE: 'ISSUE.INVALID_TYPE', + INVALID_POSITION: 'ISSUE.INVALID_POSITION', + PARENT_NOT_FOUND: 'ISSUE.PARENT_NOT_FOUND', + SELF_PARENT: 'ISSUE.SELF_PARENT', + + // 403 — Forbidden + ACCESS_DENIED: 'ISSUE.ACCESS_DENIED', + + // 422 — Unprocessable + HAS_SUBTASKS: 'ISSUE.HAS_SUBTASKS', + CANNOT_MOVE_TO_DIFFERENT_AREA: 'ISSUE.CANNOT_MOVE_TO_DIFFERENT_AREA', + + // 500 — Internal + CREATE_FAILED: 'ISSUE.CREATE_FAILED', + UPDATE_FAILED: 'ISSUE.UPDATE_FAILED', + DELETE_FAILED: 'ISSUE.DELETE_FAILED', + RESTORE_FAILED: 'ISSUE.RESTORE_FAILED', + MOVE_FAILED: 'ISSUE.MOVE_FAILED', + ASSIGN_FAILED: 'ISSUE.ASSIGN_FAILED', +} as const; + +export type IssueErrorCode = (typeof IssueErrorCodes)[keyof typeof IssueErrorCodes]; + +export const IssueErrorMessages: Record = { + [IssueErrorCodes.NOT_FOUND]: 'Задача не найдена', + + [IssueErrorCodes.TITLE_DUPLICATE]: 'Задача с таким заголовком уже существует в этой колонке', + [IssueErrorCodes.ALREADY_DELETED]: 'Задача уже удалена', + [IssueErrorCodes.NOT_DELETED]: 'Задача не удалена, восстановление не требуется', + + [IssueErrorCodes.TITLE_REQUIRED]: 'Заголовок задачи не может быть пустым', + [IssueErrorCodes.TITLE_TOO_LONG]: 'Заголовок задачи слишком длинный (максимум 255 символов)', + [IssueErrorCodes.AREA_REQUIRED]: 'ID области обязателен для создания задачи', + [IssueErrorCodes.INVALID_PRIORITY]: 'Недопустимый приоритет задачи', + [IssueErrorCodes.INVALID_TYPE]: 'Недопустимый тип задачи', + [IssueErrorCodes.INVALID_POSITION]: 'Позиция должна быть неотрицательным целым числом', + [IssueErrorCodes.PARENT_NOT_FOUND]: 'Родительская задача не найдена', + [IssueErrorCodes.SELF_PARENT]: 'Задача не может быть родителем самой себя', + + [IssueErrorCodes.ACCESS_DENIED]: 'У вас нет доступа к этой задаче', + + [IssueErrorCodes.HAS_SUBTASKS]: 'Нельзя удалить задачу, у которой есть активные подзадачи', + [IssueErrorCodes.CANNOT_MOVE_TO_DIFFERENT_AREA]: + 'Нельзя переместить задачу в другую область через этот метод', + + [IssueErrorCodes.CREATE_FAILED]: 'Не удалось создать задачу', + [IssueErrorCodes.UPDATE_FAILED]: 'Не удалось обновить задачу', + [IssueErrorCodes.DELETE_FAILED]: 'Не удалось удалить задачу', + [IssueErrorCodes.RESTORE_FAILED]: 'Не удалось восстановить задачу', + [IssueErrorCodes.MOVE_FAILED]: 'Не удалось переместить задачу', + [IssueErrorCodes.ASSIGN_FAILED]: 'Не удалось назначить исполнителя', +} as const; diff --git a/src/issue/domain/repositories/index.ts b/src/issue/domain/repositories/index.ts new file mode 100644 index 0000000..f929f20 --- /dev/null +++ b/src/issue/domain/repositories/index.ts @@ -0,0 +1 @@ +export * from './issue.repository.interface'; diff --git a/src/issue/domain/repositories/issue.repository.interface.ts b/src/issue/domain/repositories/issue.repository.interface.ts new file mode 100644 index 0000000..1084c0f --- /dev/null +++ b/src/issue/domain/repositories/issue.repository.interface.ts @@ -0,0 +1,12 @@ +// eslint-disable-next-line no-restricted-syntax +import type { IssueQueryDto } from '../../application/dtos'; +import type { Issue, NewIssue } from '../entities'; + +export interface IIssueRepository { + create(data: NewIssue, userId: string): Promise<{ id: string }>; + update(id: string, data: Partial, userId: string): Promise; + delete(id: string, userId: string): Promise; + findOne(id: string, userId: string): Promise; + find(query: IssueQueryDto): Promise; + restore(id: string, userId: string): Promise; +} diff --git a/src/issue/index.ts b/src/issue/index.ts new file mode 100644 index 0000000..85ce7b7 --- /dev/null +++ b/src/issue/index.ts @@ -0,0 +1 @@ +export { IssueModule } from './issue.module'; diff --git a/src/issue/infrastructure/persistence/models/enum.ts b/src/issue/infrastructure/persistence/models/enum.ts new file mode 100644 index 0000000..289e92e --- /dev/null +++ b/src/issue/infrastructure/persistence/models/enum.ts @@ -0,0 +1,7 @@ +import { baseSchema } from '@shared/entities'; + +import { PRIORITY, ISSUE_TYPE } from '../../../domain/entities/enum'; + +export const priorityEnum = baseSchema.enum('priority', PRIORITY); + +export const issueTypeEnum = baseSchema.enum('issue_type', ISSUE_TYPE); diff --git a/src/issue/infrastructure/persistence/models/index.ts b/src/issue/infrastructure/persistence/models/index.ts new file mode 100644 index 0000000..7b19b87 --- /dev/null +++ b/src/issue/infrastructure/persistence/models/index.ts @@ -0,0 +1,2 @@ +export { issues } from './issue.model'; +export { issueTypeEnum, priorityEnum } from './enum'; diff --git a/src/issue/infrastructure/persistence/models/issue.model.ts b/src/issue/infrastructure/persistence/models/issue.model.ts new file mode 100644 index 0000000..62ccce9 --- /dev/null +++ b/src/issue/infrastructure/persistence/models/issue.model.ts @@ -0,0 +1,74 @@ +import { createId } from '@paralleldrive/cuid2'; +import { areas, baseSchema, states, users } from '@shared/entities'; +import { sql } from 'drizzle-orm'; +import { timestamp, integer, varchar, text, index, check } from 'drizzle-orm/pg-core'; + +import { issueTypeEnum, priorityEnum } from './enum'; + +export const issues = baseSchema.table( + 'issues', + { + id: text('id') + .primaryKey() + .$defaultFn(() => createId()), + title: varchar('title', { length: 255 }).notNull(), + description: text('description'), + descriptionHtml: text('description_html'), + priority: priorityEnum('priority').default('medium').notNull(), + type: issueTypeEnum('type').default('task').notNull(), + areaId: text('area_id') + .notNull() + .references(() => areas.id, { onDelete: 'cascade' }), + stateId: text('state_id').references(() => states.id, { + onDelete: 'set null', + }), + position: integer('position').default(0), + assigneeId: text('assignee_id').references(() => users.id, { + onDelete: 'set null', + }), + reporterId: text('reporter_id').references(() => users.id, { + onDelete: 'set null', + }), + parentId: text('parent_id').references((): any => issues.id, { + onDelete: 'set null', + }), + storyPoints: integer('story_points'), + dueDate: timestamp('due_date', { withTimezone: true, mode: 'string' }), + createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }) + .defaultNow() + .notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' }) + .defaultNow() + .notNull(), + deletedAt: timestamp('deleted_at', { withTimezone: true, mode: 'string' }), + }, + (table) => ({ + areaStateIdx: index('idx_issue_area_state') + .on(table.areaId, table.stateId, table.position) + .where(sql`${table.deletedAt} IS NULL`), + assigneeIdx: index('idx_issue_assignee') + .on(table.assigneeId) + .where(sql`${table.deletedAt} IS NULL`), + parentIdx: index('idx_issue_parent') + .on(table.parentId) + .where(sql`${table.deletedAt} IS NULL`), + priorityIdx: index('idx_issue_priority') + .on(table.priority) + .where(sql`${table.deletedAt} IS NULL`), + typeIdx: index('idx_issue_type') + .on(table.type) + .where(sql`${table.deletedAt} IS NULL`), + + searchIdx: index('idx_issue_search') + .using( + 'gin', + sql`to_tsvector('english', COALESCE(${table.title}, '') || ' ' || COALESCE(${table.description}, ''))`, + ) + .where(sql`${table.deletedAt} IS NULL`), + + noSelfParent: check( + 'no_self_parent', + sql`${table.parentId} IS NULL OR ${table.parentId} != ${table.id}`, + ), + }), +); diff --git a/src/issue/infrastructure/persistence/repositories/index.ts b/src/issue/infrastructure/persistence/repositories/index.ts new file mode 100644 index 0000000..2cfbc09 --- /dev/null +++ b/src/issue/infrastructure/persistence/repositories/index.ts @@ -0,0 +1,8 @@ +import { IssueRepository } from './issue.repository'; + +export const REPOSITORIES = [ + { + provide: 'IIssueRepository', + useClass: IssueRepository, + }, +]; diff --git a/src/issue/infrastructure/persistence/repositories/issue.repository.ts b/src/issue/infrastructure/persistence/repositories/issue.repository.ts new file mode 100644 index 0000000..babd647 --- /dev/null +++ b/src/issue/infrastructure/persistence/repositories/issue.repository.ts @@ -0,0 +1,123 @@ +import * as scUsers from '@core/user/infrastructure/persistence/models'; +import { DATABASE_SERVICE, DatabaseService } from '@libs/database'; +import { Inject } from '@nestjs/common'; +import { aliasedTable, and, eq, getTableColumns, isNull, sql, type SQL } from 'drizzle-orm'; + +import { IssueQueryDto } from '../../../application/dtos'; +import * as schema from '../models/issue.model'; + +import type { IIssueRepository } from '../../../domain/repositories'; + +export class IssueRepository implements IIssueRepository { + private readonly assigneeUsers = aliasedTable(scUsers.users, 'assignee_users'); + private readonly reporterUsers = aliasedTable(scUsers.users, 'reporter_users'); + private readonly parentIssues = aliasedTable(schema.issues, 'parent_issues'); + + constructor( + @Inject(DATABASE_SERVICE) + private readonly db: DatabaseService, + ) {} + + public async create(data: typeof schema.issues.$inferInsert, userId: string) { + const [result] = await this.db + .insert(schema.issues) + .values({ ...data, reporterId: userId }) + .returning({ id: schema.issues.id }); + + if (!result) { + throw new Error('Failed to create issue: no issue returned'); + } + + return result; + } + + public async delete(id: string, _userId: string) { + const result = await this.db + .update(schema.issues) + .set({ + deletedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }) + .where(and(eq(schema.issues.id, id), isNull(schema.issues.deletedAt))); + + return (result.count ?? 0) > 0; + } + + public async find(_query: IssueQueryDto) { + const conditions: SQL[] = [isNull(schema.issues.deletedAt)]; + + return this.baseIssueQuery.where(and(...conditions)); + } + + public async findOne(id: string, _userId: string) { + const [result] = await this.baseIssueQuery.where( + and(eq(schema.issues.id, id), isNull(schema.issues.deletedAt)), + ); + + return result ?? null; + } + + public async restore(id: string, _userId: string) { + const result = await this.db + .update(schema.issues) + .set({ + deletedAt: null, + updatedAt: new Date().toISOString(), + }) + .where(eq(schema.issues.id, id)); + + return (result.count ?? 0) > 0; + } + + public async update( + id: string, + data: Partial, + _userId: string, + ) { + const result = await this.db + .update(schema.issues) + .set(data) + .where(and(eq(schema.issues.id, id), isNull(schema.issues.deletedAt))) + .returning({ id: schema.issues.id }); + + if (!result) { + throw new Error('Failed to update issue: no issue returned'); + } + + return result.length > 0; + } + + private get baseIssueQuery() { + return this.db + .select(this.issueSelection) + .from(schema.issues) + .leftJoin(this.parentIssues, eq(schema.issues.parentId, this.parentIssues.id)) + .leftJoin(this.assigneeUsers, eq(schema.issues.assigneeId, this.assigneeUsers.id)) + .leftJoin(this.reporterUsers, eq(schema.issues.reporterId, this.reporterUsers.id)); + } + + private get issueSelection() { + const { assigneeId, reporterId, parentId, ...issuesColumns } = getTableColumns( + schema.issues, + ); + + return { + ...issuesColumns, + parent: { + id: schema.issues.parentId, + title: this.parentIssues.title, + }, + assignee: this.getUserSelection(this.assigneeUsers), + reporter: this.getUserSelection(this.reporterUsers), + }; + } + + private getUserSelection(table: typeof scUsers.users) { + return { + id: table.id, + name: sql`concat(${table.firstName}, ' ', ${table.lastName})`, + avatarUrl: table.avatarUrl, + email: table.email, + }; + } +} diff --git a/src/issue/issue.module.ts b/src/issue/issue.module.ts new file mode 100644 index 0000000..b90c9e6 --- /dev/null +++ b/src/issue/issue.module.ts @@ -0,0 +1,15 @@ +import { AreaModule } from '@core/area'; +import { ProjectModule } from '@core/project'; +import { Module } from '@nestjs/common'; + +import { CONTROLLERS } from './application/controllers'; +import { IssueFacade } from './application/issue.facade'; +import { USE_CASES } from './application/use-cases'; +import { REPOSITORIES } from './infrastructure/persistence/repositories'; + +@Module({ + imports: [AreaModule, ProjectModule], + controllers: CONTROLLERS, + providers: [...REPOSITORIES, ...USE_CASES, IssueFacade], +}) +export class IssueModule {} diff --git a/src/project/application/use-cases/member/find-project-member.query.ts b/src/project/application/use-cases/member/find-project-member.query.ts new file mode 100644 index 0000000..0e7c1e7 --- /dev/null +++ b/src/project/application/use-cases/member/find-project-member.query.ts @@ -0,0 +1,11 @@ +import { IMemberRepository } from '@core/project/domain/repository'; +import { Inject, Injectable } from '@nestjs/common'; + +@Injectable() +export class FindProjectMemberQuery { + constructor(@Inject('IMemberRepository') private readonly memberRepo: IMemberRepository) {} + + execute(projectId: string, memberId: string) { + return this.memberRepo.findByProjectAndUser(projectId, memberId); + } +} diff --git a/src/project/application/use-cases/member/index.ts b/src/project/application/use-cases/member/index.ts index b25b53c..f3373d4 100644 --- a/src/project/application/use-cases/member/index.ts +++ b/src/project/application/use-cases/member/index.ts @@ -1,6 +1,7 @@ import { AddProjectMemberUseCase } from './add.use-case'; import { DeleteProjectMemberUseCase } from './delete.use-case'; import { FindAllProjectMembersQuery } from './find-all.query'; +import { FindProjectMemberQuery } from './find-project-member.query'; import { GetAvailableTeamMemberQuery } from './get-available.query'; import { UpdateProjectMemberUseCase } from './update.use-case'; @@ -9,8 +10,13 @@ export * from './delete.use-case'; export * from './find-all.query'; export * from './get-available.query'; export * from './update.use-case'; +export * from './find-project-member.query'; -export const MemberQueries = [FindAllProjectMembersQuery, GetAvailableTeamMemberQuery]; +export const MemberQueries = [ + FindAllProjectMembersQuery, + GetAvailableTeamMemberQuery, + FindProjectMemberQuery, +]; export const MemberUseCases = [ AddProjectMemberUseCase, diff --git a/src/project/application/use-cases/project/check-visibility.query.ts b/src/project/application/use-cases/project/check-visibility.query.ts new file mode 100644 index 0000000..0aeb6ed --- /dev/null +++ b/src/project/application/use-cases/project/check-visibility.query.ts @@ -0,0 +1,27 @@ +import { ProjectErrorCodes, ProjectErrorMessages } from '@core/project/domain/errors'; +import { IProjectRepository } from '@core/project/domain/repository'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { BaseException } from '@shared/error'; + +@Injectable() +export class CheckVisibilityOrThrowQuery { + constructor( + @Inject('IProjectRepository') + private readonly projectsRepo: IProjectRepository, + ) {} + async execute(slug: string) { + const result = await this.projectsRepo.checkVisibility(slug); + + if (!result) { + throw new BaseException( + { + code: ProjectErrorCodes.NOT_FOUND, + message: ProjectErrorMessages[ProjectErrorCodes.NOT_FOUND], + }, + HttpStatus.NOT_FOUND, + ); + } + + return result.visibility; + } +} diff --git a/src/project/application/use-cases/project/index.ts b/src/project/application/use-cases/project/index.ts index 3039bda..1bfe4e4 100644 --- a/src/project/application/use-cases/project/index.ts +++ b/src/project/application/use-cases/project/index.ts @@ -1,4 +1,5 @@ import { CheckSlugAvailabilityQuery } from './check-slug.use-case'; +import { CheckVisibilityOrThrowQuery } from './check-visibility.query'; import { CreateProjectUseCase } from './create.use-case'; import { DeleteProjectUseCase } from './delete.use-case'; import { FindProjectsByTeamQuery } from './find-by-team.query'; @@ -17,6 +18,7 @@ export * from './get-detail.query'; export * from './set-status.use-case'; export * from './update.use-case'; export * from './check-slug.use-case'; +export * from './check-visibility.query'; export const ProjectUseCases = [ CreateProjectUseCase, @@ -24,6 +26,7 @@ export const ProjectUseCases = [ GenerateShareTokenUseCase, SetProjectStatusUseCase, UpdateProjectUseCase, + CheckVisibilityOrThrowQuery, ]; export const ProjectQueries = [ diff --git a/src/project/domain/repository/project.repository.interface.ts b/src/project/domain/repository/project.repository.interface.ts index 319fa6b..144547d 100644 --- a/src/project/domain/repository/project.repository.interface.ts +++ b/src/project/domain/repository/project.repository.interface.ts @@ -17,4 +17,5 @@ export interface IProjectRepository { revokeAllShares(projectId: string): Promise; countByTeam(teamId: string): Promise; + checkVisibility(slug: string): Promise | null>; } diff --git a/src/project/infrastructure/persistence/repositories/project.repository.ts b/src/project/infrastructure/persistence/repositories/project.repository.ts index 6d84c14..3a24871 100644 --- a/src/project/infrastructure/persistence/repositories/project.repository.ts +++ b/src/project/infrastructure/persistence/repositories/project.repository.ts @@ -174,4 +174,13 @@ export class ProjectRepository implements IProjectRepository { return result?.count ?? 0; }; + + public readonly checkVisibility = async (slug: string) => { + const [result] = await this.db + .select({ visibility: schema.projects.visibility }) + .from(schema.projects) + .where(and(eq(schema.projects.slug, slug), isNull(schema.projects.deletedAt))); + + return result ?? null; + }; } diff --git a/src/project/project.module.ts b/src/project/project.module.ts index 6fbd286..fe4d037 100644 --- a/src/project/project.module.ts +++ b/src/project/project.module.ts @@ -4,7 +4,13 @@ import { forwardRef, Module } from '@nestjs/common'; import { CONTROLLERS } from './application/controllers'; import { ProjectFacade } from './application/project.facade'; -import { CreateProjectUseCase, FindProjectQuery, USE_CASES } from './application/use-cases'; +import { + CreateProjectUseCase, + FindProjectQuery, + FindProjectMemberQuery, + USE_CASES, + CheckVisibilityOrThrowQuery, +} from './application/use-cases'; import { POLICIES, ProjectAccessPolicy } from './domain/policy'; import { REPOSITORIES } from './infrastructure/persistence/repositories'; @@ -12,6 +18,12 @@ import { REPOSITORIES } from './infrastructure/persistence/repositories'; imports: [UserModule, forwardRef(() => TeamsModule)], controllers: CONTROLLERS, providers: [...REPOSITORIES, ...POLICIES, ...USE_CASES, ProjectFacade], - exports: [FindProjectQuery, ProjectAccessPolicy, CreateProjectUseCase], + exports: [ + FindProjectQuery, + ProjectAccessPolicy, + CreateProjectUseCase, + FindProjectMemberQuery, + CheckVisibilityOrThrowQuery, + ], }) export class ProjectModule {} diff --git a/src/shared/entities/index.ts b/src/shared/entities/index.ts index b357898..c385bea 100644 --- a/src/shared/entities/index.ts +++ b/src/shared/entities/index.ts @@ -4,3 +4,4 @@ export * from '../../auth/infrastructure/persistence/models'; export * from '../../teams/infrastructure/persistence/models'; export * from '../../project/infrastructure/persistence/models'; export * from '../../area/infrastructure/persistence/models'; +export * from '../../issue/infrastructure/persistence/models';