diff --git a/agents.md b/agents.md new file mode 100644 index 000000000..779763bd6 --- /dev/null +++ b/agents.md @@ -0,0 +1,6 @@ +# Agents Directory + +Agent guidance lives in the `.cursor` directory. + +- Directory: `.cursor/` +- Rules: `.cursor/rules/backend-api.mdc`, `.cursor/rules/database-schema.mdc`, `.cursor/rules/deployment-devops.mdc`, `.cursor/rules/development-conventions.mdc`, `.cursor/rules/frontend-web.mdc`, `.cursor/rules/project-overview.mdc` diff --git a/apps/api/drizzle/0009_fine_gertrude_yorkes.sql b/apps/api/drizzle/0009_fine_gertrude_yorkes.sql new file mode 100644 index 000000000..5ea940502 --- /dev/null +++ b/apps/api/drizzle/0009_fine_gertrude_yorkes.sql @@ -0,0 +1,3 @@ +ALTER TABLE "project" ADD COLUMN "archived_at" timestamp;--> statement-breakpoint +ALTER TABLE "project" ADD COLUMN "archived_by" text;--> statement-breakpoint +ALTER TABLE "project" ADD CONSTRAINT "project_archived_by_user_id_fk" FOREIGN KEY ("archived_by") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE cascade; \ No newline at end of file diff --git a/apps/api/drizzle/0010_big_rocket_raccoon.sql b/apps/api/drizzle/0010_big_rocket_raccoon.sql new file mode 100644 index 000000000..bc5ddafb0 --- /dev/null +++ b/apps/api/drizzle/0010_big_rocket_raccoon.sql @@ -0,0 +1,15 @@ +CREATE TABLE "audit_log" ( + "id" text PRIMARY KEY NOT NULL, + "workspace_id" text NOT NULL, + "actor_id" text, + "action" text NOT NULL, + "resource_type" text NOT NULL, + "resource_id" text NOT NULL, + "metadata" text, + "created_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "audit_log" ADD CONSTRAINT "audit_log_workspace_id_workspace_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspace"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint +ALTER TABLE "audit_log" ADD CONSTRAINT "audit_log_actor_id_user_id_fk" FOREIGN KEY ("actor_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE cascade;--> statement-breakpoint +CREATE INDEX "audit_log_workspaceId_idx" ON "audit_log" USING btree ("workspace_id");--> statement-breakpoint +CREATE INDEX "audit_log_resource_idx" ON "audit_log" USING btree ("resource_type","resource_id"); \ No newline at end of file diff --git a/apps/api/drizzle/meta/0009_snapshot.json b/apps/api/drizzle/meta/0009_snapshot.json new file mode 100644 index 000000000..1970921dc --- /dev/null +++ b/apps/api/drizzle/meta/0009_snapshot.json @@ -0,0 +1,1573 @@ +{ + "id": "d7c29fb4-3920-4167-80ed-abb4aa9615ab", + "prevId": "e1f2cb4b-1889-4d18-acc6-7909b3a97579", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.activity": { + "name": "activity", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "activity_task_id_task_id_fk": { + "name": "activity_task_id_task_id_fk", + "tableFrom": "activity", + "tableTo": "task", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "activity_user_id_user_id_fk": { + "name": "activity_user_id_user_id_fk", + "tableFrom": "activity", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.apikey": { + "name": "apikey", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "start": { + "name": "start", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refill_interval": { + "name": "refill_interval", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "refill_amount": { + "name": "refill_amount", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "rate_limit_enabled": { + "name": "rate_limit_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "rate_limit_time_window": { + "name": "rate_limit_time_window", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 86400000 + }, + "rate_limit_max": { + "name": "rate_limit_max", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10 + }, + "request_count": { + "name": "request_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "remaining": { + "name": "remaining", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_request": { + "name": "last_request", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "apikey_key_idx": { + "name": "apikey_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "apikey_userId_idx": { + "name": "apikey_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "apikey_user_id_user_id_fk": { + "name": "apikey_user_id_user_id_fk", + "tableFrom": "apikey", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_integration": { + "name": "github_integration", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repository_owner": { + "name": "repository_owner", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repository_name": { + "name": "repository_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "github_integration_project_id_project_id_fk": { + "name": "github_integration_project_id_project_id_fk", + "tableFrom": "github_integration", + "tableTo": "project", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "github_integration_project_id_unique": { + "name": "github_integration_project_id_unique", + "nullsNotDistinct": false, + "columns": ["project_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "invitation_workspaceId_idx": { + "name": "invitation_workspaceId_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_workspace_id_workspace_id_fk": { + "name": "invitation_workspace_id_workspace_id_fk", + "tableFrom": "invitation", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.label": { + "name": "label", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "label_task_id_task_id_fk": { + "name": "label_task_id_task_id_fk", + "tableFrom": "label", + "tableTo": "task", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "label_workspace_id_workspace_id_fk": { + "name": "label_workspace_id_workspace_id_fk", + "tableFrom": "label", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification": { + "name": "notification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'info'" + }, + "is_read": { + "name": "is_read", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "notification_user_id_user_id_fk": { + "name": "notification_user_id_user_id_fk", + "tableFrom": "notification", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project": { + "name": "project", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'Layout'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "archived_by": { + "name": "archived_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "project_workspace_id_workspace_id_fk": { + "name": "project_workspace_id_workspace_id_fk", + "tableFrom": "project", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "project_archived_by_user_id_fk": { + "name": "project_archived_by_user_id_fk", + "tableFrom": "project", + "tableTo": "user", + "columnsFrom": ["archived_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "active_team_id": { + "name": "active_team_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.task": { + "name": "task", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "number": { + "name": "number", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "assignee_id": { + "name": "assignee_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 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'to-do'" + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'low'" + }, + "due_date": { + "name": "due_date", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "task_project_id_project_id_fk": { + "name": "task_project_id_project_id_fk", + "tableFrom": "task", + "tableTo": "project", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "task_assignee_id_user_id_fk": { + "name": "task_assignee_id_user_id_fk", + "tableFrom": "task", + "tableTo": "user", + "columnsFrom": ["assignee_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team_member": { + "name": "team_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "teamMember_teamId_idx": { + "name": "teamMember_teamId_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "teamMember_userId_idx": { + "name": "teamMember_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "team_member_team_id_team_id_fk": { + "name": "team_member_team_id_team_id_fk", + "tableFrom": "team_member", + "tableTo": "team", + "columnsFrom": ["team_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_member_user_id_user_id_fk": { + "name": "team_member_user_id_user_id_fk", + "tableFrom": "team_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team": { + "name": "team", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "team_workspaceId_idx": { + "name": "team_workspaceId_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "team_workspace_id_workspace_id_fk": { + "name": "team_workspace_id_workspace_id_fk", + "tableFrom": "team", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.time_entry": { + "name": "time_entry", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "start_time": { + "name": "start_time", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "end_time": { + "name": "end_time", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "duration": { + "name": "duration", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "time_entry_task_id_task_id_fk": { + "name": "time_entry_task_id_task_id_fk", + "tableFrom": "time_entry", + "tableTo": "task", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "time_entry_user_id_user_id_fk": { + "name": "time_entry_user_id_user_id_fk", + "tableFrom": "time_entry", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_anonymous": { + "name": "is_anonymous", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace": { + "name": "workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_slug_unique": { + "name": "workspace_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_member": { + "name": "workspace_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "workspace_member_workspaceId_idx": { + "name": "workspace_member_workspaceId_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_member_userId_idx": { + "name": "workspace_member_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_member_workspace_id_workspace_id_fk": { + "name": "workspace_member_workspace_id_workspace_id_fk", + "tableFrom": "workspace_member", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_member_user_id_user_id_fk": { + "name": "workspace_member_user_id_user_id_fk", + "tableFrom": "workspace_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/apps/api/drizzle/meta/0010_snapshot.json b/apps/api/drizzle/meta/0010_snapshot.json new file mode 100644 index 000000000..574af9ca9 --- /dev/null +++ b/apps/api/drizzle/meta/0010_snapshot.json @@ -0,0 +1,1691 @@ +{ + "id": "8f26090b-ce39-4623-986f-8e4476e03cfc", + "prevId": "d7c29fb4-3920-4167-80ed-abb4aa9615ab", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.activity": { + "name": "activity", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "activity_task_id_task_id_fk": { + "name": "activity_task_id_task_id_fk", + "tableFrom": "activity", + "tableTo": "task", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "activity_user_id_user_id_fk": { + "name": "activity_user_id_user_id_fk", + "tableFrom": "activity", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.apikey": { + "name": "apikey", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "start": { + "name": "start", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refill_interval": { + "name": "refill_interval", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "refill_amount": { + "name": "refill_amount", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "rate_limit_enabled": { + "name": "rate_limit_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "rate_limit_time_window": { + "name": "rate_limit_time_window", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 86400000 + }, + "rate_limit_max": { + "name": "rate_limit_max", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10 + }, + "request_count": { + "name": "request_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "remaining": { + "name": "remaining", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_request": { + "name": "last_request", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "apikey_key_idx": { + "name": "apikey_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "apikey_userId_idx": { + "name": "apikey_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "apikey_user_id_user_id_fk": { + "name": "apikey_user_id_user_id_fk", + "tableFrom": "apikey", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_log_workspaceId_idx": { + "name": "audit_log_workspaceId_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_resource_idx": { + "name": "audit_log_resource_idx", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_log_workspace_id_workspace_id_fk": { + "name": "audit_log_workspace_id_workspace_id_fk", + "tableFrom": "audit_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "audit_log_actor_id_user_id_fk": { + "name": "audit_log_actor_id_user_id_fk", + "tableFrom": "audit_log", + "tableTo": "user", + "columnsFrom": ["actor_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_integration": { + "name": "github_integration", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repository_owner": { + "name": "repository_owner", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repository_name": { + "name": "repository_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "github_integration_project_id_project_id_fk": { + "name": "github_integration_project_id_project_id_fk", + "tableFrom": "github_integration", + "tableTo": "project", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "github_integration_project_id_unique": { + "name": "github_integration_project_id_unique", + "nullsNotDistinct": false, + "columns": ["project_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "invitation_workspaceId_idx": { + "name": "invitation_workspaceId_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_workspace_id_workspace_id_fk": { + "name": "invitation_workspace_id_workspace_id_fk", + "tableFrom": "invitation", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.label": { + "name": "label", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "label_task_id_task_id_fk": { + "name": "label_task_id_task_id_fk", + "tableFrom": "label", + "tableTo": "task", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "label_workspace_id_workspace_id_fk": { + "name": "label_workspace_id_workspace_id_fk", + "tableFrom": "label", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification": { + "name": "notification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'info'" + }, + "is_read": { + "name": "is_read", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "notification_user_id_user_id_fk": { + "name": "notification_user_id_user_id_fk", + "tableFrom": "notification", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project": { + "name": "project", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'Layout'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "archived_by": { + "name": "archived_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "project_workspace_id_workspace_id_fk": { + "name": "project_workspace_id_workspace_id_fk", + "tableFrom": "project", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "project_archived_by_user_id_fk": { + "name": "project_archived_by_user_id_fk", + "tableFrom": "project", + "tableTo": "user", + "columnsFrom": ["archived_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "active_team_id": { + "name": "active_team_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.task": { + "name": "task", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "number": { + "name": "number", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "assignee_id": { + "name": "assignee_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 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'to-do'" + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'low'" + }, + "due_date": { + "name": "due_date", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "task_project_id_project_id_fk": { + "name": "task_project_id_project_id_fk", + "tableFrom": "task", + "tableTo": "project", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "task_assignee_id_user_id_fk": { + "name": "task_assignee_id_user_id_fk", + "tableFrom": "task", + "tableTo": "user", + "columnsFrom": ["assignee_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team_member": { + "name": "team_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "teamMember_teamId_idx": { + "name": "teamMember_teamId_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "teamMember_userId_idx": { + "name": "teamMember_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "team_member_team_id_team_id_fk": { + "name": "team_member_team_id_team_id_fk", + "tableFrom": "team_member", + "tableTo": "team", + "columnsFrom": ["team_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_member_user_id_user_id_fk": { + "name": "team_member_user_id_user_id_fk", + "tableFrom": "team_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team": { + "name": "team", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "team_workspaceId_idx": { + "name": "team_workspaceId_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "team_workspace_id_workspace_id_fk": { + "name": "team_workspace_id_workspace_id_fk", + "tableFrom": "team", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.time_entry": { + "name": "time_entry", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "start_time": { + "name": "start_time", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "end_time": { + "name": "end_time", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "duration": { + "name": "duration", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "time_entry_task_id_task_id_fk": { + "name": "time_entry_task_id_task_id_fk", + "tableFrom": "time_entry", + "tableTo": "task", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "time_entry_user_id_user_id_fk": { + "name": "time_entry_user_id_user_id_fk", + "tableFrom": "time_entry", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_anonymous": { + "name": "is_anonymous", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace": { + "name": "workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_slug_unique": { + "name": "workspace_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_member": { + "name": "workspace_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "workspace_member_workspaceId_idx": { + "name": "workspace_member_workspaceId_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_member_userId_idx": { + "name": "workspace_member_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_member_workspace_id_workspace_id_fk": { + "name": "workspace_member_workspace_id_workspace_id_fk", + "tableFrom": "workspace_member", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_member_user_id_user_id_fk": { + "name": "workspace_member_user_id_user_id_fk", + "tableFrom": "workspace_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/apps/api/drizzle/meta/_journal.json b/apps/api/drizzle/meta/_journal.json index 8ea815720..896e0197b 100644 --- a/apps/api/drizzle/meta/_journal.json +++ b/apps/api/drizzle/meta/_journal.json @@ -64,6 +64,20 @@ "when": 1765392072530, "tag": "0008_square_silvermane", "breakpoints": true + }, + { + "idx": 9, + "version": "7", + "when": 1766193221069, + "tag": "0009_fine_gertrude_yorkes", + "breakpoints": true + }, + { + "idx": 10, + "version": "7", + "when": 1766193483201, + "tag": "0010_big_rocket_raccoon", + "breakpoints": true } ] } diff --git a/apps/api/src/activity/controllers/create-comment.ts b/apps/api/src/activity/controllers/create-comment.ts index c6ce706d8..4a4758c3f 100644 --- a/apps/api/src/activity/controllers/create-comment.ts +++ b/apps/api/src/activity/controllers/create-comment.ts @@ -1,7 +1,10 @@ import db from "../../database"; import { activityTable } from "../../database/schema"; +import { assertTaskWritable } from "../../utils/assert-task-writable"; async function createComment(taskId: string, userId: string, content: string) { + await assertTaskWritable(taskId); + const activity = await db.insert(activityTable).values({ taskId, type: "comment", diff --git a/apps/api/src/activity/controllers/delete-comment.ts b/apps/api/src/activity/controllers/delete-comment.ts index cf60ee061..887c44270 100644 --- a/apps/api/src/activity/controllers/delete-comment.ts +++ b/apps/api/src/activity/controllers/delete-comment.ts @@ -1,8 +1,20 @@ import { and, eq } from "drizzle-orm"; +import { HTTPException } from "hono/http-exception"; import db from "../../database"; import { activityTable } from "../../database/schema"; +import { assertTaskWritable } from "../../utils/assert-task-writable"; async function deleteComment(userId: string, id: string) { + const activity = await db.query.activityTable.findFirst({ + where: and(eq(activityTable.id, id), eq(activityTable.userId, userId)), + }); + + if (!activity) { + throw new HTTPException(404, { message: "Comment not found" }); + } + + await assertTaskWritable(activity.taskId); + const [deletedComment] = await db .delete(activityTable) .where(and(eq(activityTable.id, id), eq(activityTable.userId, userId))) diff --git a/apps/api/src/activity/controllers/update-comment.ts b/apps/api/src/activity/controllers/update-comment.ts index 49bcef420..d97071955 100644 --- a/apps/api/src/activity/controllers/update-comment.ts +++ b/apps/api/src/activity/controllers/update-comment.ts @@ -1,12 +1,27 @@ import { and, eq } from "drizzle-orm"; +import { HTTPException } from "hono/http-exception"; import db from "../../database"; import { activityTable } from "../../database/schema"; +import { assertTaskWritable } from "../../utils/assert-task-writable"; async function updateComment(userId: string, id: string, content: string) { - return await db + const activity = await db.query.activityTable.findFirst({ + where: and(eq(activityTable.id, id), eq(activityTable.userId, userId)), + }); + + if (!activity) { + throw new HTTPException(404, { message: "Comment not found" }); + } + + await assertTaskWritable(activity.taskId); + + const [updatedActivity] = await db .update(activityTable) .set({ content }) - .where(and(eq(activityTable.id, id), eq(activityTable.userId, userId))); + .where(and(eq(activityTable.id, id), eq(activityTable.userId, userId))) + .returning(); + + return updatedActivity; } export default updateComment; diff --git a/apps/api/src/database/index.ts b/apps/api/src/database/index.ts index 7a6fe24f0..99cc9acee 100644 --- a/apps/api/src/database/index.ts +++ b/apps/api/src/database/index.ts @@ -24,6 +24,7 @@ import { accountTable, activityTable, apikeyTable, + auditLogTable, githubIntegrationTable, invitationTable, labelTable, @@ -54,6 +55,7 @@ export const schema = { githubIntegrationTable, labelTable, notificationTable, + auditLogTable, projectTable, sessionTable, taskTable, diff --git a/apps/api/src/database/schema.ts b/apps/api/src/database/schema.ts index 32fc29957..4b72df5ca 100644 --- a/apps/api/src/database/schema.ts +++ b/apps/api/src/database/schema.ts @@ -206,6 +206,11 @@ export const projectTable = pgTable("project", { name: text("name").notNull(), description: text("description"), createdAt: timestamp("created_at", { mode: "date" }).defaultNow().notNull(), + archivedAt: timestamp("archived_at", { mode: "date" }), + archivedBy: text("archived_by").references(() => userTable.id, { + onDelete: "set null", + onUpdate: "cascade", + }), isPublic: boolean("is_public").default(false), }); @@ -313,6 +318,34 @@ export const notificationTable = pgTable("notification", { .notNull(), }); +export const auditLogTable = pgTable( + "audit_log", + { + id: text("id") + .$defaultFn(() => createId()) + .primaryKey(), + workspaceId: text("workspace_id") + .notNull() + .references(() => workspaceTable.id, { + onDelete: "cascade", + onUpdate: "cascade", + }), + actorId: text("actor_id").references(() => userTable.id, { + onDelete: "set null", + onUpdate: "cascade", + }), + action: text("action").notNull(), + resourceType: text("resource_type").notNull(), + resourceId: text("resource_id").notNull(), + metadata: text("metadata"), + createdAt: timestamp("created_at", { mode: "date" }).defaultNow().notNull(), + }, + (table) => [ + index("audit_log_workspaceId_idx").on(table.workspaceId), + index("audit_log_resource_idx").on(table.resourceType, table.resourceId), + ], +); + export const githubIntegrationTable = pgTable("github_integration", { id: text("id") .$defaultFn(() => createId()) diff --git a/apps/api/src/github-integration/controllers/import-issues.ts b/apps/api/src/github-integration/controllers/import-issues.ts index 1e6eb2da5..ad74fbc01 100644 --- a/apps/api/src/github-integration/controllers/import-issues.ts +++ b/apps/api/src/github-integration/controllers/import-issues.ts @@ -1,11 +1,14 @@ import { and, eq, notLike } from "drizzle-orm"; import db from "../../database"; import { githubIntegrationTable, taskTable } from "../../database/schema"; +import { assertProjectWritable } from "../../utils/assert-project-writable"; import createGithubApp from "../utils/create-github-app"; const githubApp = createGithubApp(); export async function importIssues(projectId: string) { + await assertProjectWritable(projectId); + const githubIntegration = await db.query.githubIntegrationTable.findFirst({ where: eq(githubIntegrationTable.projectId, projectId), }); diff --git a/apps/api/src/github-integration/utils/issue-closed.ts b/apps/api/src/github-integration/utils/issue-closed.ts index f22e62cc6..87f97150d 100644 --- a/apps/api/src/github-integration/utils/issue-closed.ts +++ b/apps/api/src/github-integration/utils/issue-closed.ts @@ -6,6 +6,7 @@ import { eq } from "drizzle-orm"; import type { Octokit } from "octokit"; import db from "../../database"; import { taskTable } from "../../database/schema"; +import { assertTaskWritable } from "../../utils/assert-task-writable"; import getGithubIntegrationByRepositoryId from "../controllers/get-github-integration-by-repository-id"; export type HandlerFunction< @@ -55,6 +56,8 @@ export const handleIssueClosed: HandlerFunction< } try { + await assertTaskWritable(kaneoTask.id); + await db .update(taskTable) .set({ diff --git a/apps/api/src/github-integration/utils/task-created.ts b/apps/api/src/github-integration/utils/task-created.ts index d6a01afd6..22d6a899a 100644 --- a/apps/api/src/github-integration/utils/task-created.ts +++ b/apps/api/src/github-integration/utils/task-created.ts @@ -1,6 +1,7 @@ import { eq } from "drizzle-orm"; import db from "../../database"; import { taskTable } from "../../database/schema"; +import { assertProjectWritable } from "../../utils/assert-project-writable"; import getGithubIntegration from "../controllers/get-github-integration"; import createGithubApp from "./create-github-app"; import { addLabelsToIssue } from "./create-github-labels"; @@ -24,6 +25,12 @@ export async function handleTaskCreated(data: { const { taskId, userId, title, description, priority, status, projectId } = data; + try { + await assertProjectWritable(projectId); + } catch { + return; + } + if (description?.includes("Created from GitHub issue:")) { console.log( "Skipping GitHub issue creation for task created from GitHub issue:", diff --git a/apps/api/src/label/controllers/create-label.ts b/apps/api/src/label/controllers/create-label.ts index 32965e544..83f106936 100644 --- a/apps/api/src/label/controllers/create-label.ts +++ b/apps/api/src/label/controllers/create-label.ts @@ -1,5 +1,6 @@ import db from "../../database"; import { labelTable } from "../../database/schema"; +import { assertTaskWritable } from "../../utils/assert-task-writable"; async function createLabel( name: string, @@ -7,6 +8,10 @@ async function createLabel( taskId: string | undefined, workspaceId: string, ) { + if (taskId) { + await assertTaskWritable(taskId); + } + const [label] = await db .insert(labelTable) .values({ name, color, taskId, workspaceId }) diff --git a/apps/api/src/label/controllers/delete-label.ts b/apps/api/src/label/controllers/delete-label.ts index b09be69b2..d39770890 100644 --- a/apps/api/src/label/controllers/delete-label.ts +++ b/apps/api/src/label/controllers/delete-label.ts @@ -2,9 +2,10 @@ import { eq } from "drizzle-orm"; import { HTTPException } from "hono/http-exception"; import db from "../../database"; import { labelTable } from "../../database/schema"; +import { assertTaskWritable } from "../../utils/assert-task-writable"; async function deleteLabel(id: string) { - const label = db.query.labelTable.findFirst({ + const label = await db.query.labelTable.findFirst({ where: (label, { eq }) => eq(label.id, id), }); @@ -14,6 +15,10 @@ async function deleteLabel(id: string) { }); } + if (label.taskId) { + await assertTaskWritable(label.taskId); + } + const [deletedLabel] = await db .delete(labelTable) .where(eq(labelTable.id, id)) diff --git a/apps/api/src/label/controllers/update-label.ts b/apps/api/src/label/controllers/update-label.ts index fc4063ea3..7b26413ee 100644 --- a/apps/api/src/label/controllers/update-label.ts +++ b/apps/api/src/label/controllers/update-label.ts @@ -2,6 +2,7 @@ import { eq } from "drizzle-orm"; import { HTTPException } from "hono/http-exception"; import db from "../../database"; import { labelTable } from "../../database/schema"; +import { assertTaskWritable } from "../../utils/assert-task-writable"; async function updateLabel(id: string, name: string, color: string) { const label = await db.query.labelTable.findFirst({ @@ -14,6 +15,10 @@ async function updateLabel(id: string, name: string, color: string) { }); } + if (label.taskId) { + await assertTaskWritable(label.taskId); + } + const [updatedLabel] = await db .update(labelTable) .set({ name, color }) diff --git a/apps/api/src/notification/controllers/get-notifications.ts b/apps/api/src/notification/controllers/get-notifications.ts index f100b9f00..d78715330 100644 --- a/apps/api/src/notification/controllers/get-notifications.ts +++ b/apps/api/src/notification/controllers/get-notifications.ts @@ -1,12 +1,42 @@ -import { desc, eq } from "drizzle-orm"; +import { and, desc, eq, isNull, ne, or } from "drizzle-orm"; import db from "../../database"; -import { notificationTable } from "../../database/schema"; +import { + notificationTable, + projectTable, + taskTable, +} from "../../database/schema"; async function getNotifications(userId: string) { const notifications = await db - .select() + .select({ + id: notificationTable.id, + userId: notificationTable.userId, + title: notificationTable.title, + content: notificationTable.content, + type: notificationTable.type, + isRead: notificationTable.isRead, + resourceId: notificationTable.resourceId, + resourceType: notificationTable.resourceType, + createdAt: notificationTable.createdAt, + }) .from(notificationTable) - .where(eq(notificationTable.userId, userId)) + .leftJoin( + taskTable, + and( + eq(notificationTable.resourceType, "task"), + eq(notificationTable.resourceId, taskTable.id), + ), + ) + .leftJoin(projectTable, eq(taskTable.projectId, projectTable.id)) + .where( + and( + eq(notificationTable.userId, userId), + or( + ne(notificationTable.resourceType, "task"), + isNull(projectTable.archivedAt), + ), + ), + ) .orderBy(desc(notificationTable.createdAt)) .limit(50); diff --git a/apps/api/src/project/controllers/archive-project.ts b/apps/api/src/project/controllers/archive-project.ts new file mode 100644 index 000000000..2d9de46d9 --- /dev/null +++ b/apps/api/src/project/controllers/archive-project.ts @@ -0,0 +1,63 @@ +import { and, eq } from "drizzle-orm"; +import { HTTPException } from "hono/http-exception"; +import db from "../../database"; +import { auditLogTable, projectTable } from "../../database/schema"; +import { assertWorkspaceRole } from "../../utils/assert-workspace-role"; + +async function archiveProject(options: { + projectId: string; + workspaceId: string; + actorId: string; +}) { + await assertWorkspaceRole({ + workspaceId: options.workspaceId, + userId: options.actorId, + allowedRoles: ["owner", "admin"], + }); + + const [existingProject] = await db + .select() + .from(projectTable) + .where( + and( + eq(projectTable.id, options.projectId), + eq(projectTable.workspaceId, options.workspaceId), + ), + ) + .limit(1); + + if (!existingProject) { + throw new HTTPException(404, { message: "Project not found" }); + } + + if (existingProject.archivedAt) { + return existingProject; + } + + const [archivedProject] = await db + .update(projectTable) + .set({ archivedAt: new Date(), archivedBy: options.actorId }) + .where( + and( + eq(projectTable.id, options.projectId), + eq(projectTable.workspaceId, options.workspaceId), + ), + ) + .returning(); + + if (!archivedProject) { + throw new HTTPException(500, { message: "Failed to archive project" }); + } + + await db.insert(auditLogTable).values({ + workspaceId: options.workspaceId, + actorId: options.actorId, + action: "project.archived", + resourceType: "project", + resourceId: options.projectId, + }); + + return archivedProject; +} + +export default archiveProject; diff --git a/apps/api/src/project/controllers/create-project.ts b/apps/api/src/project/controllers/create-project.ts index 066e47783..8824c9d2a 100644 --- a/apps/api/src/project/controllers/create-project.ts +++ b/apps/api/src/project/controllers/create-project.ts @@ -1,3 +1,5 @@ +import { and, eq } from "drizzle-orm"; +import { HTTPException } from "hono/http-exception"; import db from "../../database"; import { projectTable } from "../../database/schema"; @@ -7,6 +9,23 @@ async function createProject( icon: string, slug: string, ) { + const [existingProjectWithSlug] = await db + .select({ id: projectTable.id }) + .from(projectTable) + .where( + and( + eq(projectTable.workspaceId, workspaceId), + eq(projectTable.slug, slug), + ), + ) + .limit(1); + + if (existingProjectWithSlug) { + throw new HTTPException(409, { + message: "Project key is already in use in this workspace", + }); + } + const [createdProject] = await db .insert(projectTable) .values({ diff --git a/apps/api/src/project/controllers/get-archived-projects.ts b/apps/api/src/project/controllers/get-archived-projects.ts new file mode 100644 index 000000000..a48e5d375 --- /dev/null +++ b/apps/api/src/project/controllers/get-archived-projects.ts @@ -0,0 +1,47 @@ +import { and, desc, eq, isNotNull } from "drizzle-orm"; +import db from "../../database"; +import { projectTable } from "../../database/schema"; + +async function getArchivedProjects(workspaceId: string) { + const projects = await db.query.projectTable.findMany({ + where: and( + eq(projectTable.workspaceId, workspaceId), + isNotNull(projectTable.archivedAt), + ), + with: { + tasks: true, + }, + orderBy: [desc(projectTable.archivedAt)], + }); + + const projectsWithStatistics = projects.map((project) => { + const totalTasks = project.tasks.length; + const completedTasks = project.tasks.filter( + (task) => task.status === "done" || task.status === "archived", + ).length; + const completionPercentage = + totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0; + + const dueDate = project.tasks.reduce((earliest: Date | null, task) => { + if (!earliest || (task.dueDate && task.dueDate < earliest)) + return task.dueDate; + return earliest; + }, null); + + return { + ...project, + statistics: { + completionPercentage, + totalTasks, + dueDate, + }, + archivedTasks: [], + plannedTasks: [], + columns: [], + }; + }); + + return projectsWithStatistics; +} + +export default getArchivedProjects; diff --git a/apps/api/src/project/controllers/get-projects.ts b/apps/api/src/project/controllers/get-projects.ts index d6a4f71cc..34b2c519b 100644 --- a/apps/api/src/project/controllers/get-projects.ts +++ b/apps/api/src/project/controllers/get-projects.ts @@ -1,10 +1,13 @@ -import { eq } from "drizzle-orm"; +import { and, eq, isNull } from "drizzle-orm"; import db from "../../database"; import { projectTable } from "../../database/schema"; async function getProjects(workspaceId: string) { const projects = await db.query.projectTable.findMany({ - where: eq(projectTable.workspaceId, workspaceId), + where: and( + eq(projectTable.workspaceId, workspaceId), + isNull(projectTable.archivedAt), + ), with: { tasks: true, }, diff --git a/apps/api/src/project/controllers/unarchive-project.ts b/apps/api/src/project/controllers/unarchive-project.ts new file mode 100644 index 000000000..b3654a936 --- /dev/null +++ b/apps/api/src/project/controllers/unarchive-project.ts @@ -0,0 +1,63 @@ +import { and, eq } from "drizzle-orm"; +import { HTTPException } from "hono/http-exception"; +import db from "../../database"; +import { auditLogTable, projectTable } from "../../database/schema"; +import { assertWorkspaceRole } from "../../utils/assert-workspace-role"; + +async function unarchiveProject(options: { + projectId: string; + workspaceId: string; + actorId: string; +}) { + await assertWorkspaceRole({ + workspaceId: options.workspaceId, + userId: options.actorId, + allowedRoles: ["owner", "admin"], + }); + + const [existingProject] = await db + .select() + .from(projectTable) + .where( + and( + eq(projectTable.id, options.projectId), + eq(projectTable.workspaceId, options.workspaceId), + ), + ) + .limit(1); + + if (!existingProject) { + throw new HTTPException(404, { message: "Project not found" }); + } + + if (!existingProject.archivedAt) { + return existingProject; + } + + const [unarchivedProject] = await db + .update(projectTable) + .set({ archivedAt: null, archivedBy: null }) + .where( + and( + eq(projectTable.id, options.projectId), + eq(projectTable.workspaceId, options.workspaceId), + ), + ) + .returning(); + + if (!unarchivedProject) { + throw new HTTPException(500, { message: "Failed to unarchive project" }); + } + + await db.insert(auditLogTable).values({ + workspaceId: options.workspaceId, + actorId: options.actorId, + action: "project.unarchived", + resourceType: "project", + resourceId: options.projectId, + }); + + return unarchivedProject; +} + +export default unarchiveProject; diff --git a/apps/api/src/project/controllers/update-project.ts b/apps/api/src/project/controllers/update-project.ts index 722de1266..8dcaf9aed 100644 --- a/apps/api/src/project/controllers/update-project.ts +++ b/apps/api/src/project/controllers/update-project.ts @@ -1,4 +1,4 @@ -import { and, eq } from "drizzle-orm"; +import { and, eq, ne } from "drizzle-orm"; import { HTTPException } from "hono/http-exception"; import db from "../../database"; import { projectTable } from "../../database/schema"; @@ -28,6 +28,30 @@ async function updateProject( }); } + if (existingProject.archivedAt) { + throw new HTTPException(403, { + message: "Project is archived and read-only", + }); + } + + const [projectWithSameSlug] = await db + .select({ id: projectTable.id }) + .from(projectTable) + .where( + and( + eq(projectTable.workspaceId, workspaceId), + eq(projectTable.slug, slug), + ne(projectTable.id, id), + ), + ) + .limit(1); + + if (projectWithSameSlug) { + throw new HTTPException(409, { + message: "Project key is already in use in this workspace", + }); + } + const [updatedWorkspace] = await db .update(projectTable) .set({ diff --git a/apps/api/src/project/index.ts b/apps/api/src/project/index.ts index 302767e4f..03d8c6031 100644 --- a/apps/api/src/project/index.ts +++ b/apps/api/src/project/index.ts @@ -3,10 +3,13 @@ import { describeRoute, resolver, validator } from "hono-openapi"; import * as v from "valibot"; import { projectSchema } from "../schemas"; import { workspaceAccess } from "../utils/workspace-access-middleware"; +import archiveProjectCtrl from "./controllers/archive-project"; import createProjectCtrl from "./controllers/create-project"; import deleteProjectCtrl from "./controllers/delete-project"; +import getArchivedProjectsCtrl from "./controllers/get-archived-projects"; import getProjectCtrl from "./controllers/get-project"; import getProjectsCtrl from "./controllers/get-projects"; +import unarchiveProjectCtrl from "./controllers/unarchive-project"; import updateProjectCtrl from "./controllers/update-project"; const project = new Hono<{ @@ -38,6 +41,29 @@ const project = new Hono<{ return c.json(projects); }, ) + .get( + "/archived", + describeRoute({ + operationId: "listArchivedProjects", + tags: ["Projects"], + description: "Get archived projects in a workspace", + responses: { + 200: { + description: "List of archived projects with statistics", + content: { + "application/json": { schema: resolver(v.array(projectSchema)) }, + }, + }, + }, + }), + validator("query", v.object({ workspaceId: v.string() })), + workspaceAccess.fromQuery(), + async (c) => { + const workspaceId = c.get("workspaceId"); + const projects = await getArchivedProjectsCtrl(workspaceId); + return c.json(projects); + }, + ) .post( "/", describeRoute({ @@ -70,6 +96,66 @@ const project = new Hono<{ return c.json(newProject); }, ) + .post( + "/:id/archive", + describeRoute({ + operationId: "archiveProject", + tags: ["Projects"], + description: "Archive a project", + responses: { + 200: { + description: "Project archived successfully", + content: { + "application/json": { schema: resolver(projectSchema) }, + }, + }, + }, + }), + validator("param", v.object({ id: v.string() })), + validator("query", v.object({ workspaceId: v.optional(v.string()) })), + workspaceAccess.fromProject(), + async (c) => { + const { id } = c.req.valid("param"); + const workspaceId = c.get("workspaceId"); + const actorId = c.get("userId"); + const archivedProject = await archiveProjectCtrl({ + projectId: id, + workspaceId, + actorId, + }); + return c.json(archivedProject); + }, + ) + .post( + "/:id/unarchive", + describeRoute({ + operationId: "unarchiveProject", + tags: ["Projects"], + description: "Unarchive a project", + responses: { + 200: { + description: "Project unarchived successfully", + content: { + "application/json": { schema: resolver(projectSchema) }, + }, + }, + }, + }), + validator("param", v.object({ id: v.string() })), + validator("query", v.object({ workspaceId: v.optional(v.string()) })), + workspaceAccess.fromProject(), + async (c) => { + const { id } = c.req.valid("param"); + const workspaceId = c.get("workspaceId"); + const actorId = c.get("userId"); + const unarchivedProject = await unarchiveProjectCtrl({ + projectId: id, + workspaceId, + actorId, + }); + return c.json(unarchivedProject); + }, + ) .get( "/:id", describeRoute({ @@ -86,7 +172,7 @@ const project = new Hono<{ }, }), validator("param", v.object({ id: v.string() })), - validator("query", v.optional(v.object({ workspaceId: v.string() }))), + validator("query", v.object({ workspaceId: v.optional(v.string()) })), workspaceAccess.fromProject(), async (c) => { const { id } = c.req.valid("param"); @@ -154,7 +240,7 @@ const project = new Hono<{ }, }), validator("param", v.object({ id: v.string() })), - validator("query", v.optional(v.object({ workspaceId: v.string() }))), + validator("query", v.object({ workspaceId: v.optional(v.string()) })), workspaceAccess.fromProject(), async (c) => { const { id } = c.req.valid("param"); diff --git a/apps/api/src/schemas.ts b/apps/api/src/schemas.ts index c15c27337..188d4de6a 100644 --- a/apps/api/src/schemas.ts +++ b/apps/api/src/schemas.ts @@ -17,6 +17,8 @@ export const projectSchema = v.object({ name: v.string(), description: v.nullable(v.string()), createdAt: v.date(), + archivedAt: v.nullable(v.date()), + archivedBy: v.nullable(v.string()), isPublic: v.nullable(v.boolean()), }); diff --git a/apps/api/src/search/controllers/global-search.ts b/apps/api/src/search/controllers/global-search.ts index de6efac2c..b8f73c34b 100644 --- a/apps/api/src/search/controllers/global-search.ts +++ b/apps/api/src/search/controllers/global-search.ts @@ -1,4 +1,4 @@ -import { and, desc, eq, ilike, or, sql } from "drizzle-orm"; +import { and, desc, eq, ilike, isNull, or, sql } from "drizzle-orm"; import db from "../../database"; import { activityTable, @@ -131,6 +131,7 @@ async function globalSearch(params: SearchParams): Promise<{ .where( and( workspaceFilter, + isNull(projectTable.archivedAt), projectId ? eq(taskTable.projectId, projectId) : undefined, or( ilike(taskTable.title, searchPattern), @@ -190,6 +191,7 @@ async function globalSearch(params: SearchParams): Promise<{ .where( and( workspaceFilter, + isNull(projectTable.archivedAt), or( ilike(projectTable.name, searchPattern), ilike(projectTable.description, searchPattern), @@ -302,6 +304,7 @@ async function globalSearch(params: SearchParams): Promise<{ .where( and( workspaceFilter, + isNull(projectTable.archivedAt), projectId ? eq(taskTable.projectId, projectId) : undefined, or( ilike(activityTable.content, searchPattern), diff --git a/apps/api/src/task/controllers/create-task.ts b/apps/api/src/task/controllers/create-task.ts index e7ffa079c..3cb1b6769 100644 --- a/apps/api/src/task/controllers/create-task.ts +++ b/apps/api/src/task/controllers/create-task.ts @@ -3,6 +3,7 @@ import { HTTPException } from "hono/http-exception"; import db from "../../database"; import { taskTable, userTable } from "../../database/schema"; import { publishEvent } from "../../events"; +import { assertProjectWritable } from "../../utils/assert-project-writable"; import getNextTaskNumber from "./get-next-task-number"; async function createTask({ @@ -22,6 +23,8 @@ async function createTask({ description?: string; priority?: string; }) { + await assertProjectWritable(projectId); + const [assignee] = await db .select({ name: userTable.name }) .from(userTable) diff --git a/apps/api/src/task/controllers/delete-task.ts b/apps/api/src/task/controllers/delete-task.ts index c56190e90..8aa08af33 100644 --- a/apps/api/src/task/controllers/delete-task.ts +++ b/apps/api/src/task/controllers/delete-task.ts @@ -1,21 +1,17 @@ import { eq } from "drizzle-orm"; -import { HTTPException } from "hono/http-exception"; import db from "../../database"; import { taskTable } from "../../database/schema"; +import { assertTaskWritable } from "../../utils/assert-task-writable"; async function deleteTask(taskId: string) { + await assertTaskWritable(taskId); + const task = await db .delete(taskTable) .where(eq(taskTable.id, taskId)) .returning() .execute(); - if (!task) { - throw new HTTPException(404, { - message: "Task not found", - }); - } - return task; } diff --git a/apps/api/src/task/controllers/get-tasks.ts b/apps/api/src/task/controllers/get-tasks.ts index 57fb0912e..e539c479a 100644 --- a/apps/api/src/task/controllers/get-tasks.ts +++ b/apps/api/src/task/controllers/get-tasks.ts @@ -62,6 +62,8 @@ async function getTasks(projectId: string) { slug: project.slug, icon: project.icon, description: project.description, + archivedAt: project.archivedAt, + archivedBy: project.archivedBy, isPublic: project.isPublic, workspaceId: project.workspaceId, columns, diff --git a/apps/api/src/task/controllers/import-tasks.ts b/apps/api/src/task/controllers/import-tasks.ts index 2ae3bd598..fc8623062 100644 --- a/apps/api/src/task/controllers/import-tasks.ts +++ b/apps/api/src/task/controllers/import-tasks.ts @@ -25,6 +25,12 @@ async function importTasks(projectId: string, tasksToImport: ImportTask[]) { }); } + if (project.archivedAt) { + throw new HTTPException(403, { + message: "Project is archived and read-only", + }); + } + const nextTaskNumber = await getNextTaskNumber(projectId); let taskNumber = nextTaskNumber; diff --git a/apps/api/src/task/controllers/update-task-assignee.ts b/apps/api/src/task/controllers/update-task-assignee.ts index fad7adb5d..c476cb2d8 100644 --- a/apps/api/src/task/controllers/update-task-assignee.ts +++ b/apps/api/src/task/controllers/update-task-assignee.ts @@ -2,6 +2,7 @@ import { eq } from "drizzle-orm"; import { HTTPException } from "hono/http-exception"; import db from "../../database"; import { taskTable } from "../../database/schema"; +import { assertTaskWritable } from "../../utils/assert-task-writable"; async function updateTaskAssignee({ id, @@ -10,6 +11,8 @@ async function updateTaskAssignee({ id: string; userId: string; }) { + await assertTaskWritable(id); + const updatedTask = await db.query.taskTable.findFirst({ where: eq(taskTable.id, id), }); diff --git a/apps/api/src/task/controllers/update-task-description.ts b/apps/api/src/task/controllers/update-task-description.ts index bf9fffd53..3395f0bc5 100644 --- a/apps/api/src/task/controllers/update-task-description.ts +++ b/apps/api/src/task/controllers/update-task-description.ts @@ -2,6 +2,7 @@ import { eq } from "drizzle-orm"; import { HTTPException } from "hono/http-exception"; import db from "../../database"; import { taskTable } from "../../database/schema"; +import { assertTaskWritable } from "../../utils/assert-task-writable"; async function updateTaskDescription({ id, @@ -10,6 +11,8 @@ async function updateTaskDescription({ id: string; description: string; }) { + await assertTaskWritable(id); + const existingTask = await db.query.taskTable.findFirst({ where: eq(taskTable.id, id), }); diff --git a/apps/api/src/task/controllers/update-task-due-date.ts b/apps/api/src/task/controllers/update-task-due-date.ts index b32a75268..4272a1f2a 100644 --- a/apps/api/src/task/controllers/update-task-due-date.ts +++ b/apps/api/src/task/controllers/update-task-due-date.ts @@ -2,6 +2,7 @@ import { eq } from "drizzle-orm"; import { HTTPException } from "hono/http-exception"; import db from "../../database"; import { taskTable } from "../../database/schema"; +import { assertTaskWritable } from "../../utils/assert-task-writable"; async function updateTaskDueDate({ id, @@ -10,6 +11,8 @@ async function updateTaskDueDate({ id: string; dueDate: Date; }) { + await assertTaskWritable(id); + const updatedTask = await db.query.taskTable.findFirst({ where: eq(taskTable.id, id), }); diff --git a/apps/api/src/task/controllers/update-task-priority.ts b/apps/api/src/task/controllers/update-task-priority.ts index 66c0787d6..1818e687c 100644 --- a/apps/api/src/task/controllers/update-task-priority.ts +++ b/apps/api/src/task/controllers/update-task-priority.ts @@ -2,6 +2,7 @@ import { eq } from "drizzle-orm"; import { HTTPException } from "hono/http-exception"; import db from "../../database"; import { taskTable } from "../../database/schema"; +import { assertTaskWritable } from "../../utils/assert-task-writable"; async function updateTaskPriority({ id, @@ -10,6 +11,8 @@ async function updateTaskPriority({ id: string; priority: string; }) { + await assertTaskWritable(id); + const updatedTask = await db.query.taskTable.findFirst({ where: eq(taskTable.id, id), }); diff --git a/apps/api/src/task/controllers/update-task-status.ts b/apps/api/src/task/controllers/update-task-status.ts index 46e2879b5..575cb0169 100644 --- a/apps/api/src/task/controllers/update-task-status.ts +++ b/apps/api/src/task/controllers/update-task-status.ts @@ -2,6 +2,7 @@ import { eq } from "drizzle-orm"; import { HTTPException } from "hono/http-exception"; import db from "../../database"; import { taskTable } from "../../database/schema"; +import { assertTaskWritable } from "../../utils/assert-task-writable"; async function updateTaskStatus({ id, @@ -10,6 +11,8 @@ async function updateTaskStatus({ id: string; status: string; }) { + await assertTaskWritable(id); + const updatedTask = await db.query.taskTable.findFirst({ where: eq(taskTable.id, id), }); diff --git a/apps/api/src/task/controllers/update-task-title.ts b/apps/api/src/task/controllers/update-task-title.ts index f32715bb0..a049f0a0f 100644 --- a/apps/api/src/task/controllers/update-task-title.ts +++ b/apps/api/src/task/controllers/update-task-title.ts @@ -2,8 +2,11 @@ import { eq } from "drizzle-orm"; import { HTTPException } from "hono/http-exception"; import db from "../../database"; import { taskTable } from "../../database/schema"; +import { assertTaskWritable } from "../../utils/assert-task-writable"; async function updateTaskTitle({ id, title }: { id: string; title: string }) { + await assertTaskWritable(id); + const existingTask = await db.query.taskTable.findFirst({ where: eq(taskTable.id, id), }); diff --git a/apps/api/src/task/controllers/update-task.ts b/apps/api/src/task/controllers/update-task.ts index 25053545e..409253207 100644 --- a/apps/api/src/task/controllers/update-task.ts +++ b/apps/api/src/task/controllers/update-task.ts @@ -2,6 +2,8 @@ import { eq } from "drizzle-orm"; import { HTTPException } from "hono/http-exception"; import db from "../../database"; import { taskTable } from "../../database/schema"; +import { assertProjectWritable } from "../../utils/assert-project-writable"; +import { assertTaskWritable } from "../../utils/assert-task-writable"; async function updateTask( id: string, @@ -14,6 +16,9 @@ async function updateTask( position: number, userId?: string, ) { + await assertTaskWritable(id); + await assertProjectWritable(projectId); + const existingTask = await db.query.taskTable.findFirst({ where: eq(taskTable.id, id), }); diff --git a/apps/api/src/time-entry/controllers/create-time-entry.ts b/apps/api/src/time-entry/controllers/create-time-entry.ts index ed56398a8..3ce9343c1 100644 --- a/apps/api/src/time-entry/controllers/create-time-entry.ts +++ b/apps/api/src/time-entry/controllers/create-time-entry.ts @@ -4,6 +4,7 @@ import { HTTPException } from "hono/http-exception"; import db from "../../database"; import { taskTable, timeEntryTable } from "../../database/schema"; import { publishEvent } from "../../events"; +import { assertTaskWritable } from "../../utils/assert-task-writable"; async function createTimeEntry({ taskId, @@ -20,6 +21,8 @@ async function createTimeEntry({ endTime?: Date; duration?: number; }) { + await assertTaskWritable(taskId); + const [createdTimeEntry] = await db .insert(timeEntryTable) .values({ diff --git a/apps/api/src/time-entry/controllers/update-time-entry.ts b/apps/api/src/time-entry/controllers/update-time-entry.ts index dc824d8d6..0f7bd6732 100644 --- a/apps/api/src/time-entry/controllers/update-time-entry.ts +++ b/apps/api/src/time-entry/controllers/update-time-entry.ts @@ -2,6 +2,7 @@ import { eq } from "drizzle-orm"; import { HTTPException } from "hono/http-exception"; import db from "../../database"; import { timeEntryTable } from "../../database/schema"; +import { assertTaskWritable } from "../../utils/assert-task-writable"; type UpdateTimeEntryParams = { timeEntryId: string; @@ -24,6 +25,8 @@ async function updateTimeEntry(params: UpdateTimeEntryParams) { }); } + await assertTaskWritable(existingTimeEntry.taskId); + // Calculate duration if both startTime and endTime are provided let duration: number | null = null; if (endTime) { diff --git a/apps/api/src/utils/assert-project-writable.ts b/apps/api/src/utils/assert-project-writable.ts new file mode 100644 index 000000000..169a351db --- /dev/null +++ b/apps/api/src/utils/assert-project-writable.ts @@ -0,0 +1,22 @@ +import { eq } from "drizzle-orm"; +import { HTTPException } from "hono/http-exception"; +import db from "../database"; +import { projectTable } from "../database/schema"; + +export async function assertProjectWritable(projectId: string): Promise { + const [project] = await db + .select({ archivedAt: projectTable.archivedAt }) + .from(projectTable) + .where(eq(projectTable.id, projectId)) + .limit(1); + + if (!project) { + throw new HTTPException(404, { message: "Project not found" }); + } + + if (project.archivedAt) { + throw new HTTPException(403, { + message: "Project is archived and read-only", + }); + } +} diff --git a/apps/api/src/utils/assert-task-writable.ts b/apps/api/src/utils/assert-task-writable.ts new file mode 100644 index 000000000..4a21445f1 --- /dev/null +++ b/apps/api/src/utils/assert-task-writable.ts @@ -0,0 +1,25 @@ +import { eq } from "drizzle-orm"; +import { HTTPException } from "hono/http-exception"; +import db from "../database"; +import { projectTable, taskTable } from "../database/schema"; + +export async function assertTaskWritable(taskId: string): Promise { + const [row] = await db + .select({ + archivedAt: projectTable.archivedAt, + }) + .from(taskTable) + .innerJoin(projectTable, eq(taskTable.projectId, projectTable.id)) + .where(eq(taskTable.id, taskId)) + .limit(1); + + if (!row) { + throw new HTTPException(404, { message: "Task not found" }); + } + + if (row.archivedAt) { + throw new HTTPException(403, { + message: "Project is archived and read-only", + }); + } +} diff --git a/apps/api/src/utils/assert-workspace-role.ts b/apps/api/src/utils/assert-workspace-role.ts new file mode 100644 index 000000000..58696073d --- /dev/null +++ b/apps/api/src/utils/assert-workspace-role.ts @@ -0,0 +1,36 @@ +import { and, eq } from "drizzle-orm"; +import { HTTPException } from "hono/http-exception"; +import db, { schema } from "../database"; + +export type WorkspaceRole = "owner" | "admin" | "member"; + +export async function assertWorkspaceRole(options: { + workspaceId: string; + userId: string; + allowedRoles: WorkspaceRole[]; +}): Promise { + const membership = await db + .select({ role: schema.workspaceUserTable.role }) + .from(schema.workspaceUserTable) + .where( + and( + eq(schema.workspaceUserTable.workspaceId, options.workspaceId), + eq(schema.workspaceUserTable.userId, options.userId), + ), + ) + .limit(1); + + const role = membership[0]?.role as WorkspaceRole | undefined; + + if (!role) { + throw new HTTPException(403, { + message: "You don't have access to this workspace", + }); + } + + if (!options.allowedRoles.includes(role)) { + throw new HTTPException(403, { + message: "You don't have permission to manage projects in this workspace", + }); + } +} diff --git a/apps/docs/next-env.d.ts b/apps/docs/next-env.d.ts index 9edff1c7c..c4b7818fb 100644 --- a/apps/docs/next-env.d.ts +++ b/apps/docs/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/web/src/components/nav-projects.tsx b/apps/web/src/components/nav-projects.tsx index d20820d6b..d91ea1776 100644 --- a/apps/web/src/components/nav-projects.tsx +++ b/apps/web/src/components/nav-projects.tsx @@ -1,6 +1,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { useNavigate, useParams } from "@tanstack/react-router"; import { + Archive, ChevronRight, Folder, Forward, @@ -34,9 +35,11 @@ import { } from "@/components/ui/sidebar"; import icons from "@/constants/project-icons"; import { shortcuts } from "@/constants/shortcuts"; +import useArchiveProject from "@/hooks/mutations/project/use-archive-project"; import useDeleteProject from "@/hooks/mutations/project/use-delete-project"; import useGetProjects from "@/hooks/queries/project/use-get-projects"; import useActiveWorkspace from "@/hooks/queries/workspace/use-active-workspace"; +import { useWorkspacePermission } from "@/hooks/use-workspace-permission"; import { cn } from "@/lib/cn"; import type { ProjectWithTasks } from "@/types/project"; import CreateProjectModal from "./shared/modals/create-project-modal"; @@ -61,10 +64,12 @@ import { export function NavProjects() { const { isMobile } = useSidebar(); const { data: workspace } = useActiveWorkspace(); + const { canManageProjects } = useWorkspacePermission(); const { data: projects } = useGetProjects({ workspaceId: workspace?.id || "", }); const queryClient = useQueryClient(); + const { mutateAsync: archiveProject } = useArchiveProject(); const { mutateAsync: deleteProject } = useDeleteProject(); const navigate = useNavigate(); const { workspaceId: currentWorkspaceId, projectId: currentProjectId } = @@ -177,8 +182,38 @@ export function NavProjects() { Share Project + { + try { + await archiveProject({ + id: project.id, + workspaceId: workspace.id, + }); + toast.success("Project archived"); + queryClient.invalidateQueries({ + queryKey: ["projects"], + }); + navigate({ + to: "/dashboard/workspace/$workspaceId", + params: { workspaceId: workspace?.id || "" }, + }); + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : "Failed to archive project", + ); + } + }} + > + + Archive Project + { setProjectToDeleteID(project.id); setIsDeleteProjectModalOpen(true); @@ -249,6 +284,7 @@ export function NavProjects() { onClick={async () => { await deleteProject({ id: projectToDeleteId || "", + workspaceId: workspace.id, }); toast.success("Project deleted"); queryClient.invalidateQueries({ diff --git a/apps/web/src/fetchers/project/archive-project.ts b/apps/web/src/fetchers/project/archive-project.ts new file mode 100644 index 000000000..88498526b --- /dev/null +++ b/apps/web/src/fetchers/project/archive-project.ts @@ -0,0 +1,31 @@ +import { client } from "@kaneo/libs"; +import type { InferRequestType } from "hono/client"; + +export type ArchiveProjectRequest = InferRequestType< + (typeof client)["project"][":id"]["archive"]["$post"] +>["param"] & + Partial< + NonNullable< + InferRequestType< + (typeof client)["project"][":id"]["archive"]["$post"] + >["query"] + > + >; + +async function archiveProject({ id, workspaceId }: ArchiveProjectRequest) { + const response = await client.project[":id"].archive.$post({ + param: { id }, + query: workspaceId ? { workspaceId } : {}, + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(error); + } + + const data = await response.json(); + + return data; +} + +export default archiveProject; diff --git a/apps/web/src/fetchers/project/delete-project.ts b/apps/web/src/fetchers/project/delete-project.ts index 7bcd52197..9a853bf9e 100644 --- a/apps/web/src/fetchers/project/delete-project.ts +++ b/apps/web/src/fetchers/project/delete-project.ts @@ -3,10 +3,18 @@ import type { InferRequestType } from "hono/client"; export type DeleteProjectRequest = InferRequestType< (typeof client)["project"][":id"]["$delete"] ->["param"]; +>["param"] & + Partial< + NonNullable< + InferRequestType<(typeof client)["project"][":id"]["$delete"]>["query"] + > + >; -async function deleteProject({ id }: DeleteProjectRequest) { - const response = await client.project[":id"].$delete({ param: { id } }); +async function deleteProject({ id, workspaceId }: DeleteProjectRequest) { + const response = await client.project[":id"].$delete({ + param: { id }, + query: workspaceId ? { workspaceId } : {}, + }); if (!response.ok) { const error = await response.text(); diff --git a/apps/web/src/fetchers/project/get-archived-projects.ts b/apps/web/src/fetchers/project/get-archived-projects.ts new file mode 100644 index 000000000..a53212eaf --- /dev/null +++ b/apps/web/src/fetchers/project/get-archived-projects.ts @@ -0,0 +1,27 @@ +import { client } from "@kaneo/libs"; +import type { InferRequestType } from "hono/client"; + +export type GetArchivedProjectsRequest = InferRequestType< + (typeof client)["project"]["archived"]["$get"] +>["query"]; + +async function getArchivedProjects({ + workspaceId, +}: GetArchivedProjectsRequest) { + if (!workspaceId) return; + + const response = await client.project.archived.$get({ + query: { workspaceId }, + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(error); + } + + const data = await response.json(); + + return data; +} + +export default getArchivedProjects; diff --git a/apps/web/src/fetchers/project/unarchive-project.ts b/apps/web/src/fetchers/project/unarchive-project.ts new file mode 100644 index 000000000..31e7e93e4 --- /dev/null +++ b/apps/web/src/fetchers/project/unarchive-project.ts @@ -0,0 +1,31 @@ +import { client } from "@kaneo/libs"; +import type { InferRequestType } from "hono/client"; + +export type UnarchiveProjectRequest = InferRequestType< + (typeof client)["project"][":id"]["unarchive"]["$post"] +>["param"] & + Partial< + NonNullable< + InferRequestType< + (typeof client)["project"][":id"]["unarchive"]["$post"] + >["query"] + > + >; + +async function unarchiveProject({ id, workspaceId }: UnarchiveProjectRequest) { + const response = await client.project[":id"].unarchive.$post({ + param: { id }, + query: workspaceId ? { workspaceId } : {}, + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(error); + } + + const data = await response.json(); + + return data; +} + +export default unarchiveProject; diff --git a/apps/web/src/hooks/mutations/project/use-archive-project.ts b/apps/web/src/hooks/mutations/project/use-archive-project.ts new file mode 100644 index 000000000..221fc8666 --- /dev/null +++ b/apps/web/src/hooks/mutations/project/use-archive-project.ts @@ -0,0 +1,10 @@ +import { useMutation } from "@tanstack/react-query"; +import archiveProject from "@/fetchers/project/archive-project"; + +function useArchiveProject() { + return useMutation({ + mutationFn: archiveProject, + }); +} + +export default useArchiveProject; diff --git a/apps/web/src/hooks/mutations/project/use-unarchive-project.ts b/apps/web/src/hooks/mutations/project/use-unarchive-project.ts new file mode 100644 index 000000000..2192e00c6 --- /dev/null +++ b/apps/web/src/hooks/mutations/project/use-unarchive-project.ts @@ -0,0 +1,10 @@ +import { useMutation } from "@tanstack/react-query"; +import unarchiveProject from "@/fetchers/project/unarchive-project"; + +function useUnarchiveProject() { + return useMutation({ + mutationFn: unarchiveProject, + }); +} + +export default useUnarchiveProject; diff --git a/apps/web/src/hooks/queries/project/use-get-archived-projects.ts b/apps/web/src/hooks/queries/project/use-get-archived-projects.ts new file mode 100644 index 000000000..fb26b9844 --- /dev/null +++ b/apps/web/src/hooks/queries/project/use-get-archived-projects.ts @@ -0,0 +1,18 @@ +import { useQuery } from "@tanstack/react-query"; +import getArchivedProjects from "@/fetchers/project/get-archived-projects"; + +function useGetArchivedProjects({ + workspaceId, + enabled = true, +}: { + workspaceId: string; + enabled?: boolean; +}) { + return useQuery({ + queryFn: () => getArchivedProjects({ workspaceId }), + queryKey: ["projects", workspaceId, "archived"], + enabled: Boolean(workspaceId) && enabled, + }); +} + +export default useGetArchivedProjects; diff --git a/apps/web/src/hooks/queries/project/use-get-projects.ts b/apps/web/src/hooks/queries/project/use-get-projects.ts index 486a3495e..936206785 100644 --- a/apps/web/src/hooks/queries/project/use-get-projects.ts +++ b/apps/web/src/hooks/queries/project/use-get-projects.ts @@ -1,11 +1,17 @@ import { useQuery } from "@tanstack/react-query"; import getProjects from "@/fetchers/project/get-projects"; -function useGetProjects({ workspaceId }: { workspaceId: string }) { +function useGetProjects({ + workspaceId, + enabled = true, +}: { + workspaceId: string; + enabled?: boolean; +}) { return useQuery({ queryFn: () => getProjects({ workspaceId }), queryKey: ["projects", workspaceId], - enabled: !!workspaceId, + enabled: Boolean(workspaceId) && enabled, }); } diff --git a/apps/web/src/hooks/use-workspace-permission.ts b/apps/web/src/hooks/use-workspace-permission.ts index 5a1205a69..582098a8c 100644 --- a/apps/web/src/hooks/use-workspace-permission.ts +++ b/apps/web/src/hooks/use-workspace-permission.ts @@ -9,102 +9,72 @@ export function useWorkspacePermission() { const { data: activeWorkspace } = useActiveWorkspace(); const { data: activeMember } = useGetActiveWorkspaceUser(); - const permissionCheckers = useMemo( - () => ({ - // Server-side permission checking (most secure) - async hasPermission(permissions: Record) { - try { - const result = await authClient.organization.hasPermission({ - permissions, - }); - return result.data || false; - } catch (error) { - console.error("Permission check failed:", error); - return false; - } - }, + const permissionCheckers = useMemo(() => { + const role = activeMember?.role as PermissionLevel | undefined; - // Client-side role-based checking (faster for UI) - checkRolePermission(permissions: Record) { - if (!activeMember?.role) return false; - try { - return authClient.organization.checkRolePermission({ - permissions, - role: activeMember.role as PermissionLevel, - }); - } catch (error) { - console.error("Role permission check failed:", error); - return false; - } - }, - - // Convenience methods for common checks - canManageProjects() { - return this.checkRolePermission({ - project: ["create", "update", "delete"], - }); - }, - - canCreateProjects() { - return this.checkRolePermission({ project: ["create"] }); - }, - - canManageTasks() { - return this.checkRolePermission({ - task: ["create", "update", "delete"], + const hasPermission = async (permissions: Record) => { + try { + const result = await authClient.organization.hasPermission({ + permissions, }); - }, - - canAssignTasks() { - return this.checkRolePermission({ task: ["assign"] }); - }, - - canManageWorkspace() { - return this.checkRolePermission({ - workspace: ["update", "manage_settings"], + return result.data || false; + } catch (error) { + console.error("Permission check failed:", error); + return false; + } + }; + + const checkRolePermission = (permissions: Record) => { + if (!role) return false; + try { + return authClient.organization.checkRolePermission({ + permissions, + role, }); - }, - - canDeleteWorkspace() { - return this.checkRolePermission({ workspace: ["delete"] }); - }, - - canInviteUsers() { - return this.checkRolePermission({ team: ["invite"] }); - }, - - canManageTeam() { - return this.checkRolePermission({ team: ["remove", "manage_roles"] }); - }, - - canRemoveMembers() { - return this.checkRolePermission({ team: ["remove"] }); - }, - - // Legacy compatibility method - checkPermission(requiredRole: PermissionLevel = "member"): boolean { - if (!activeWorkspace || !activeMember) return false; - - const userRole = activeMember.role as PermissionLevel; - - if (requiredRole === "owner") { - return userRole === "owner"; - } - - if (requiredRole === "admin") { - return ["owner", "admin"].includes(userRole); - } - - // For member level, all roles have access - return ["owner", "admin", "member"].includes(userRole); - }, - - isOwner: activeMember?.role === "owner", - isAdmin: ["owner", "admin"].includes(activeMember?.role || ""), - role: activeMember?.role as PermissionLevel | undefined, - }), - [activeMember, activeWorkspace], - ); + } catch (error) { + console.error("Role permission check failed:", error); + return false; + } + }; + + const checkPermission = (requiredRole: PermissionLevel = "member") => { + if (!activeWorkspace || !activeMember) return false; + + const userRole = activeMember.role as PermissionLevel; + + if (requiredRole === "owner") { + return userRole === "owner"; + } + + if (requiredRole === "admin") { + return ["owner", "admin"].includes(userRole); + } + + return ["owner", "admin", "member"].includes(userRole); + }; + + return { + hasPermission, + checkRolePermission, + canManageProjects: () => + checkRolePermission({ project: ["create", "update", "delete"] }), + canCreateProjects: () => checkRolePermission({ project: ["create"] }), + canManageTasks: () => + checkRolePermission({ task: ["create", "update", "delete"] }), + canAssignTasks: () => checkRolePermission({ task: ["assign"] }), + canManageWorkspace: () => + checkRolePermission({ workspace: ["update", "manage_settings"] }), + canDeleteWorkspace: () => checkRolePermission({ workspace: ["delete"] }), + canInviteUsers: () => checkRolePermission({ team: ["invite"] }), + canManageTeam: () => + checkRolePermission({ team: ["remove", "manage_roles"] }), + canRemoveMembers: () => checkRolePermission({ team: ["remove"] }), + checkPermission, + isOwner: role === "owner", + isAdmin: ["owner", "admin"].includes(role || ""), + role, + }; + }, [activeMember, activeWorkspace]); return { ...permissionCheckers, diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 2f93602fb..1cd4b3bae 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -33,6 +33,7 @@ import { Route as LayoutAuthenticatedDashboardWorkspaceWorkspaceIdIndexRouteImpo import { Route as LayoutAuthenticatedDashboardWorkspaceWorkspaceIdSearchRouteImport } from './routes/_layout/_authenticated/dashboard/workspace/$workspaceId/search' import { Route as LayoutAuthenticatedDashboardWorkspaceWorkspaceIdMembersRouteImport } from './routes/_layout/_authenticated/dashboard/workspace/$workspaceId/members' import { Route as LayoutAuthenticatedDashboardSettingsWorkspaceGeneralRouteImport } from './routes/_layout/_authenticated/dashboard/settings/workspace/general' +import { Route as LayoutAuthenticatedDashboardSettingsProjectsArchivedRouteImport } from './routes/_layout/_authenticated/dashboard/settings/projects/archived' import { Route as LayoutAuthenticatedDashboardSettingsAccountPreferencesRouteImport } from './routes/_layout/_authenticated/dashboard/settings/account/preferences' import { Route as LayoutAuthenticatedDashboardSettingsAccountInformationRouteImport } from './routes/_layout/_authenticated/dashboard/settings/account/information' import { Route as LayoutAuthenticatedDashboardSettingsAccountDeveloperRouteImport } from './routes/_layout/_authenticated/dashboard/settings/account/developer' @@ -177,6 +178,12 @@ const LayoutAuthenticatedDashboardSettingsWorkspaceGeneralRoute = path: '/general', getParentRoute: () => LayoutAuthenticatedDashboardSettingsWorkspaceRoute, } as any) +const LayoutAuthenticatedDashboardSettingsProjectsArchivedRoute = + LayoutAuthenticatedDashboardSettingsProjectsArchivedRouteImport.update({ + id: '/archived', + path: '/archived', + getParentRoute: () => LayoutAuthenticatedDashboardSettingsProjectsRoute, + } as any) const LayoutAuthenticatedDashboardSettingsAccountPreferencesRoute = LayoutAuthenticatedDashboardSettingsAccountPreferencesRouteImport.update({ id: '/preferences', @@ -278,6 +285,7 @@ export interface FileRoutesByFullPath { '/dashboard/settings/account/developer': typeof LayoutAuthenticatedDashboardSettingsAccountDeveloperRoute '/dashboard/settings/account/information': typeof LayoutAuthenticatedDashboardSettingsAccountInformationRoute '/dashboard/settings/account/preferences': typeof LayoutAuthenticatedDashboardSettingsAccountPreferencesRoute + '/dashboard/settings/projects/archived': typeof LayoutAuthenticatedDashboardSettingsProjectsArchivedRoute '/dashboard/settings/workspace/general': typeof LayoutAuthenticatedDashboardSettingsWorkspaceGeneralRoute '/dashboard/workspace/$workspaceId/members': typeof LayoutAuthenticatedDashboardWorkspaceWorkspaceIdMembersRoute '/dashboard/workspace/$workspaceId/search': typeof LayoutAuthenticatedDashboardWorkspaceWorkspaceIdSearchRoute @@ -310,6 +318,7 @@ export interface FileRoutesByTo { '/dashboard/settings/account/developer': typeof LayoutAuthenticatedDashboardSettingsAccountDeveloperRoute '/dashboard/settings/account/information': typeof LayoutAuthenticatedDashboardSettingsAccountInformationRoute '/dashboard/settings/account/preferences': typeof LayoutAuthenticatedDashboardSettingsAccountPreferencesRoute + '/dashboard/settings/projects/archived': typeof LayoutAuthenticatedDashboardSettingsProjectsArchivedRoute '/dashboard/settings/workspace/general': typeof LayoutAuthenticatedDashboardSettingsWorkspaceGeneralRoute '/dashboard/workspace/$workspaceId/members': typeof LayoutAuthenticatedDashboardWorkspaceWorkspaceIdMembersRoute '/dashboard/workspace/$workspaceId/search': typeof LayoutAuthenticatedDashboardWorkspaceWorkspaceIdSearchRoute @@ -347,6 +356,7 @@ export interface FileRoutesById { '/_layout/_authenticated/dashboard/settings/account/developer': typeof LayoutAuthenticatedDashboardSettingsAccountDeveloperRoute '/_layout/_authenticated/dashboard/settings/account/information': typeof LayoutAuthenticatedDashboardSettingsAccountInformationRoute '/_layout/_authenticated/dashboard/settings/account/preferences': typeof LayoutAuthenticatedDashboardSettingsAccountPreferencesRoute + '/_layout/_authenticated/dashboard/settings/projects/archived': typeof LayoutAuthenticatedDashboardSettingsProjectsArchivedRoute '/_layout/_authenticated/dashboard/settings/workspace/general': typeof LayoutAuthenticatedDashboardSettingsWorkspaceGeneralRoute '/_layout/_authenticated/dashboard/workspace/$workspaceId/members': typeof LayoutAuthenticatedDashboardWorkspaceWorkspaceIdMembersRoute '/_layout/_authenticated/dashboard/workspace/$workspaceId/search': typeof LayoutAuthenticatedDashboardWorkspaceWorkspaceIdSearchRoute @@ -383,6 +393,7 @@ export interface FileRouteTypes { | '/dashboard/settings/account/developer' | '/dashboard/settings/account/information' | '/dashboard/settings/account/preferences' + | '/dashboard/settings/projects/archived' | '/dashboard/settings/workspace/general' | '/dashboard/workspace/$workspaceId/members' | '/dashboard/workspace/$workspaceId/search' @@ -415,6 +426,7 @@ export interface FileRouteTypes { | '/dashboard/settings/account/developer' | '/dashboard/settings/account/information' | '/dashboard/settings/account/preferences' + | '/dashboard/settings/projects/archived' | '/dashboard/settings/workspace/general' | '/dashboard/workspace/$workspaceId/members' | '/dashboard/workspace/$workspaceId/search' @@ -451,6 +463,7 @@ export interface FileRouteTypes { | '/_layout/_authenticated/dashboard/settings/account/developer' | '/_layout/_authenticated/dashboard/settings/account/information' | '/_layout/_authenticated/dashboard/settings/account/preferences' + | '/_layout/_authenticated/dashboard/settings/projects/archived' | '/_layout/_authenticated/dashboard/settings/workspace/general' | '/_layout/_authenticated/dashboard/workspace/$workspaceId/members' | '/_layout/_authenticated/dashboard/workspace/$workspaceId/search' @@ -642,6 +655,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LayoutAuthenticatedDashboardSettingsWorkspaceGeneralRouteImport parentRoute: typeof LayoutAuthenticatedDashboardSettingsWorkspaceRoute } + '/_layout/_authenticated/dashboard/settings/projects/archived': { + id: '/_layout/_authenticated/dashboard/settings/projects/archived' + path: '/archived' + fullPath: '/dashboard/settings/projects/archived' + preLoaderRoute: typeof LayoutAuthenticatedDashboardSettingsProjectsArchivedRouteImport + parentRoute: typeof LayoutAuthenticatedDashboardSettingsProjectsRoute + } '/_layout/_authenticated/dashboard/settings/account/preferences': { id: '/_layout/_authenticated/dashboard/settings/account/preferences' path: '/preferences' @@ -737,6 +757,7 @@ const LayoutAuthenticatedDashboardSettingsAccountRouteWithChildren = ) interface LayoutAuthenticatedDashboardSettingsProjectsRouteChildren { + LayoutAuthenticatedDashboardSettingsProjectsArchivedRoute: typeof LayoutAuthenticatedDashboardSettingsProjectsArchivedRoute LayoutAuthenticatedDashboardSettingsProjectsProjectIdGeneralRoute: typeof LayoutAuthenticatedDashboardSettingsProjectsProjectIdGeneralRoute LayoutAuthenticatedDashboardSettingsProjectsProjectIdIntegrationsRoute: typeof LayoutAuthenticatedDashboardSettingsProjectsProjectIdIntegrationsRoute LayoutAuthenticatedDashboardSettingsProjectsProjectIdVisibilityRoute: typeof LayoutAuthenticatedDashboardSettingsProjectsProjectIdVisibilityRoute @@ -744,6 +765,8 @@ interface LayoutAuthenticatedDashboardSettingsProjectsRouteChildren { const LayoutAuthenticatedDashboardSettingsProjectsRouteChildren: LayoutAuthenticatedDashboardSettingsProjectsRouteChildren = { + LayoutAuthenticatedDashboardSettingsProjectsArchivedRoute: + LayoutAuthenticatedDashboardSettingsProjectsArchivedRoute, LayoutAuthenticatedDashboardSettingsProjectsProjectIdGeneralRoute: LayoutAuthenticatedDashboardSettingsProjectsProjectIdGeneralRoute, LayoutAuthenticatedDashboardSettingsProjectsProjectIdIntegrationsRoute: diff --git a/apps/web/src/routes/_layout/_authenticated/dashboard/settings/projects.tsx b/apps/web/src/routes/_layout/_authenticated/dashboard/settings/projects.tsx index 253882791..ad331dc30 100644 --- a/apps/web/src/routes/_layout/_authenticated/dashboard/settings/projects.tsx +++ b/apps/web/src/routes/_layout/_authenticated/dashboard/settings/projects.tsx @@ -4,7 +4,7 @@ import { Outlet, useLocation, } from "@tanstack/react-router"; -import { ChevronRight, Eye, Plug, Settings } from "lucide-react"; +import { Archive, ChevronRight, Eye, Plug, Settings } from "lucide-react"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Collapsible, @@ -71,6 +71,20 @@ function RouteComponent() {