From 3d7ba0d6a75988f05999f53efeb4de0054084282 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gjermund=20H=C3=B8s=C3=B8ien=20Wiggen?= Date: Tue, 9 Jun 2026 14:40:00 +0200 Subject: [PATCH] feat: team assignment on tickets + My team's tickets view Backend: - team_id column on tickets table - team_id filter in GET /tickets (resolves team members) - team_id in UpdateTicketSchema + PATCH handler - SetTeam transaction type Frontend: - Team selector in ticket detail properties sidebar - My team's tickets in sidebar (when user belongs to a team) - team_id passed through to API from ticket list page Co-Authored-By: Claude Opus 4.8 --- drizzle/migrations/0006_nosy_black_queen.sql | 2 + drizzle/migrations/meta/0006_snapshot.json | 1310 ++++++++++++++++++ drizzle/migrations/meta/_journal.json | 7 + src/db/schema.ts | 1 + src/models/ticket.ts | 1 + src/routes/tickets.ts | 27 +- web/src/app/page.tsx | 2 + web/src/app/tickets/[id]/page.tsx | 51 +- web/src/components/app-shell.tsx | 10 + web/src/lib/api.ts | 4 +- web/src/lib/types.ts | 1 + 11 files changed, 1412 insertions(+), 4 deletions(-) create mode 100644 drizzle/migrations/0006_nosy_black_queen.sql create mode 100644 drizzle/migrations/meta/0006_snapshot.json diff --git a/drizzle/migrations/0006_nosy_black_queen.sql b/drizzle/migrations/0006_nosy_black_queen.sql new file mode 100644 index 0000000..2cd34b2 --- /dev/null +++ b/drizzle/migrations/0006_nosy_black_queen.sql @@ -0,0 +1,2 @@ +ALTER TABLE "tickets" ADD COLUMN "team_id" uuid;--> statement-breakpoint +ALTER TABLE "tickets" ADD CONSTRAINT "tickets_team_id_teams_id_fk" FOREIGN KEY ("team_id") REFERENCES "public"."teams"("id") ON DELETE set null ON UPDATE no action; \ No newline at end of file diff --git a/drizzle/migrations/meta/0006_snapshot.json b/drizzle/migrations/meta/0006_snapshot.json new file mode 100644 index 0000000..63c0275 --- /dev/null +++ b/drizzle/migrations/meta/0006_snapshot.json @@ -0,0 +1,1310 @@ +{ + "id": "4ee0a3d3-29c7-4d52-9f20-c0116509ffec", + "prevId": "aa46da36-6338-4ee0-bafa-77f0e4cb305e", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.custom_field_values": { + "name": "custom_field_values", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "custom_field_id": { + "name": "custom_field_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "ticket_id": { + "name": "ticket_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "custom_field_values_ticket_id_idx": { + "name": "custom_field_values_ticket_id_idx", + "columns": [ + { + "expression": "ticket_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_field_values_custom_field_id_idx": { + "name": "custom_field_values_custom_field_id_idx", + "columns": [ + { + "expression": "custom_field_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_field_values_custom_field_id_custom_fields_id_fk": { + "name": "custom_field_values_custom_field_id_custom_fields_id_fk", + "tableFrom": "custom_field_values", + "tableTo": "custom_fields", + "columnsFrom": [ + "custom_field_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_field_values_ticket_id_tickets_id_fk": { + "name": "custom_field_values_ticket_id_tickets_id_fk", + "tableFrom": "custom_field_values", + "tableTo": "tickets", + "columnsFrom": [ + "ticket_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "custom_field_values_cf_id_ticket_id_value_unique": { + "name": "custom_field_values_cf_id_ticket_id_value_unique", + "nullsNotDistinct": false, + "columns": [ + "custom_field_id", + "ticket_id", + "value" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_fields": { + "name": "custom_fields", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "field_type": { + "name": "field_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "values": { + "name": "values", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "max_values": { + "name": "max_values", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "pattern": { + "name": "pattern", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "custom_fields_key_unique": { + "name": "custom_fields_key_unique", + "nullsNotDistinct": false, + "columns": [ + "key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.dashboard_widgets": { + "name": "dashboard_widgets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "dashboard_id": { + "name": "dashboard_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "view_id": { + "name": "view_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "widget_type": { + "name": "widget_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{\"x\":0,\"y\":0,\"w\":4,\"h\":2}'" + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "dashboard_widgets_dashboard_id_dashboards_id_fk": { + "name": "dashboard_widgets_dashboard_id_dashboards_id_fk", + "tableFrom": "dashboard_widgets", + "tableTo": "dashboards", + "columnsFrom": [ + "dashboard_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "dashboard_widgets_view_id_views_id_fk": { + "name": "dashboard_widgets_view_id_views_id_fk", + "tableFrom": "dashboard_widgets", + "tableTo": "views", + "columnsFrom": [ + "view_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.dashboards": { + "name": "dashboards", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "layout": { + "name": "layout", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "dashboards_team_id_teams_id_fk": { + "name": "dashboards_team_id_teams_id_fk", + "tableFrom": "dashboards", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.lifecycles": { + "name": "lifecycles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "definition": { + "name": "definition", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "lifecycles_name_unique": { + "name": "lifecycles_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.queue_custom_fields": { + "name": "queue_custom_fields", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "queue_id": { + "name": "queue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "custom_field_id": { + "name": "custom_field_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "queue_custom_fields_queue_id_queues_id_fk": { + "name": "queue_custom_fields_queue_id_queues_id_fk", + "tableFrom": "queue_custom_fields", + "tableTo": "queues", + "columnsFrom": [ + "queue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "queue_custom_fields_custom_field_id_custom_fields_id_fk": { + "name": "queue_custom_fields_custom_field_id_custom_fields_id_fk", + "tableFrom": "queue_custom_fields", + "tableTo": "custom_fields", + "columnsFrom": [ + "custom_field_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "queue_custom_fields_queue_id_custom_field_id_unique": { + "name": "queue_custom_fields_queue_id_custom_field_id_unique", + "nullsNotDistinct": false, + "columns": [ + "queue_id", + "custom_field_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.queues": { + "name": "queues", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lifecycle_id": { + "name": "lifecycle_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "queues_lifecycle_id_lifecycles_id_fk": { + "name": "queues_lifecycle_id_lifecycles_id_fk", + "tableFrom": "queues", + "tableTo": "lifecycles", + "columnsFrom": [ + "lifecycle_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "queues_name_unique": { + "name": "queues_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scrips": { + "name": "scrips", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "queue_id": { + "name": "queue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "condition_type": { + "name": "condition_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "condition_config": { + "name": "condition_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "action_type": { + "name": "action_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action_config": { + "name": "action_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "template_id": { + "name": "template_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "stage": { + "name": "stage", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'TransactionCreate'" + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "scrips_queue_id_idx": { + "name": "scrips_queue_id_idx", + "columns": [ + { + "expression": "queue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scrips_queue_id_queues_id_fk": { + "name": "scrips_queue_id_queues_id_fk", + "tableFrom": "scrips", + "tableTo": "queues", + "columnsFrom": [ + "queue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "scrips_template_id_templates_id_fk": { + "name": "scrips_template_id_templates_id_fk", + "tableFrom": "scrips", + "tableTo": "templates", + "columnsFrom": [ + "template_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team_members": { + "name": "team_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "team_members_team_id_teams_id_fk": { + "name": "team_members_team_id_teams_id_fk", + "tableFrom": "team_members", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_members_user_id_users_id_fk": { + "name": "team_members_user_id_users_id_fk", + "tableFrom": "team_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "team_members_team_id_user_id_unique": { + "name": "team_members_team_id_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "team_id", + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.teams": { + "name": "teams", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "teams_name_unique": { + "name": "teams_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.templates": { + "name": "templates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "queue_id": { + "name": "queue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "subject_template": { + "name": "subject_template", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body_template": { + "name": "body_template", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "templates_queue_id_queues_id_fk": { + "name": "templates_queue_id_queues_id_fk", + "tableFrom": "templates", + "tableTo": "queues", + "columnsFrom": [ + "queue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tickets": { + "name": "tickets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "tickets_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "queue_id": { + "name": "queue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "creator_id": { + "name": "creator_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "tickets_queue_id_idx": { + "name": "tickets_queue_id_idx", + "columns": [ + { + "expression": "queue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tickets_status_idx": { + "name": "tickets_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tickets_queue_id_queues_id_fk": { + "name": "tickets_queue_id_queues_id_fk", + "tableFrom": "tickets", + "tableTo": "queues", + "columnsFrom": [ + "queue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "tickets_owner_id_users_id_fk": { + "name": "tickets_owner_id_users_id_fk", + "tableFrom": "tickets", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "tickets_team_id_teams_id_fk": { + "name": "tickets_team_id_teams_id_fk", + "tableFrom": "tickets", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tickets_creator_id_users_id_fk": { + "name": "tickets_creator_id_users_id_fk", + "tableFrom": "tickets", + "tableTo": "users", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.transactions": { + "name": "transactions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "ticket_id": { + "name": "ticket_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "transaction_type": { + "name": "transaction_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "field": { + "name": "field", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "old_value": { + "name": "old_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "new_value": { + "name": "new_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "creator_id": { + "name": "creator_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "transactions_ticket_id_idx": { + "name": "transactions_ticket_id_idx", + "columns": [ + { + "expression": "ticket_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "transactions_created_at_idx": { + "name": "transactions_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "transactions_ticket_id_tickets_id_fk": { + "name": "transactions_ticket_id_tickets_id_fk", + "tableFrom": "transactions", + "tableTo": "tickets", + "columnsFrom": [ + "ticket_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "transactions_creator_id_users_id_fk": { + "name": "transactions_creator_id_users_id_fk", + "tableFrom": "transactions", + "tableTo": "users", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_username_unique": { + "name": "users_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.views": { + "name": "views", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filters": { + "name": "filters", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "sort_key": { + "name": "sort_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'updated'" + }, + "columns": { + "name": "columns", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "creator_id": { + "name": "creator_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "views_creator_id_users_id_fk": { + "name": "views_creator_id_users_id_fk", + "tableFrom": "views", + "tableTo": "users", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/migrations/meta/_journal.json b/drizzle/migrations/meta/_journal.json index 37c61ad..12baece 100644 --- a/drizzle/migrations/meta/_journal.json +++ b/drizzle/migrations/meta/_journal.json @@ -43,6 +43,13 @@ "when": 1781004398567, "tag": "0005_spotty_leader", "breakpoints": true + }, + { + "idx": 6, + "version": "7", + "when": 1781008559188, + "tag": "0006_nosy_black_queen", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/db/schema.ts b/src/db/schema.ts index 50fa477..c8ad255 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -29,6 +29,7 @@ export const tickets = pgTable('tickets', { queue_id: uuid('queue_id').notNull().references(() => queues.id), status: text('status').notNull(), owner_id: uuid('owner_id').references(() => users.id), + team_id: uuid('team_id').references(() => teams.id, { onDelete: 'set null' }), creator_id: uuid('creator_id').notNull().references(() => users.id), created_at: timestamp('created_at', { withTimezone: true }).defaultNow(), updated_at: timestamp('updated_at', { withTimezone: true }).defaultNow(), diff --git a/src/models/ticket.ts b/src/models/ticket.ts index a33f40d..1a4c083 100644 --- a/src/models/ticket.ts +++ b/src/models/ticket.ts @@ -15,6 +15,7 @@ export const UpdateTicketSchema = z.object({ subject: z.string().min(1).optional(), status: z.string().min(1).optional(), owner_id: z.string().uuid().nullable().optional(), + team_id: z.string().uuid().nullable().optional(), }); export const CommentSchema = z.object({ diff --git a/src/routes/tickets.ts b/src/routes/tickets.ts index e8a8696..13e762e 100644 --- a/src/routes/tickets.ts +++ b/src/routes/tickets.ts @@ -1,7 +1,7 @@ import { Hono } from 'hono'; import { HTTPException } from 'hono/http-exception'; import type { Db } from '../db/index.ts'; -import { tickets, transactions, customFieldValues, customFields, queues, lifecycles, queueCustomFields } from '../db/schema.ts'; +import { tickets, transactions, customFieldValues, customFields, queues, lifecycles, queueCustomFields, teamMembers } from '../db/schema.ts'; import { and, eq, asc, or, ilike, isNull, inArray, exists, sql } from 'drizzle-orm'; import { CreateTicketSchema, UpdateTicketSchema, CommentSchema } from '../models/ticket.ts'; import { ScripEngine } from '../scrip/engine.ts'; @@ -26,6 +26,7 @@ export function createTicketsRouter(db: Db): Hono { const queueId = c.req.query('queue_id'); const status = c.req.query('status'); const ownerId = c.req.query('owner_id'); + const teamId = c.req.query('team_id'); const query = c.req.query('q')?.trim() ?? ''; const limit = c.req.query('limit') ? Number(c.req.query('limit')) : undefined; const cfFilters = [...params.entries()] @@ -49,6 +50,18 @@ export function createTicketsRouter(db: Db): Hono { ownerId === 'unassigned' ? isNull(tickets.owner_id) : eq(tickets.owner_id, ownerId) ); } + if (teamId) { + // Resolve team members and filter tickets by those owner_ids + const members = await db.query.teamMembers.findMany({ + where: eq(teamMembers.team_id, teamId), + }); + const memberIds = members.map((m) => m.user_id); + if (memberIds.length > 0) { + conditions.push(inArray(tickets.owner_id, memberIds)); + } else { + conditions.push(isNull(tickets.owner_id)); // empty team = no results + } + } // Text search: push to SQL via ilike on ticket columns + queue name join if (query) { @@ -323,6 +336,17 @@ export function createTicketsRouter(db: Db): Hono { }); } + if (parsed.team_id !== undefined && parsed.team_id !== (ticket as any).team_id) { + txList.push({ + ticket_id: id, + transaction_type: 'SetTeam' as const, + field: 'team_id', + old_value: (ticket as any).team_id ?? null, + new_value: parsed.team_id, + creator_id: '00000000-0000-0000-0000-000000000000', + }); + } + // Update the ticket const updateData: Record = {}; if (parsed.subject) updateData.subject = parsed.subject; @@ -348,6 +372,7 @@ export function createTicketsRouter(db: Db): Hono { } } if (parsed.owner_id !== undefined) updateData.owner_id = parsed.owner_id; + if (parsed.team_id !== undefined) updateData.team_id = parsed.team_id; updateData.updated_at = new Date(); const [updated] = await db.update(tickets) diff --git a/web/src/app/page.tsx b/web/src/app/page.tsx index 3a3a7c5..c9de44f 100644 --- a/web/src/app/page.tsx +++ b/web/src/app/page.tsx @@ -198,12 +198,14 @@ function TicketWorkbenchContent() { } } + const routeTeamId = searchParams.get("team_id") ?? ""; const [ticketsRes, queuesRes, usersRes, fieldsRes, lifecycleRes] = await Promise.all([ getTickets({ q: searchQuery.trim() || undefined, status: apiStatus || undefined, queue_id: activeQueue || apiQueue || undefined, owner_id: apiOwner || undefined, + team_id: routeTeamId || undefined, custom_fields: Object.keys(customFieldFilters).length > 0 ? customFieldFilters : undefined, }), getQueues(), diff --git a/web/src/app/tickets/[id]/page.tsx b/web/src/app/tickets/[id]/page.tsx index e0a75e3..08fe172 100644 --- a/web/src/app/tickets/[id]/page.tsx +++ b/web/src/app/tickets/[id]/page.tsx @@ -26,6 +26,7 @@ import { getQueues, getLifecycles, getUsers, + getTeams, getQueueCustomFields, previewTicket, updateTicket, @@ -37,6 +38,7 @@ import type { Transaction, Queue, Lifecycle, + Team, User, QueueCustomField, PreviewResult, @@ -212,6 +214,7 @@ export default function TicketDetailPage({ const [queue, setQueue] = useState(null); const [lifecycles, setLifecycles] = useState([]); const [users, setUsers] = useState([]); + const [teams, setTeams] = useState([]); const [queueFields, setQueueFields] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -230,7 +233,7 @@ export default function TicketDetailPage({ const [scripResults, setScripResults] = useState(null); const [editingSubject, setEditingSubject] = useState(false); const [subjectDraft, setSubjectDraft] = useState(""); - const [fieldSaving, setFieldSaving] = useState<"subject" | "owner" | null>(null); + const [fieldSaving, setFieldSaving] = useState<"subject" | "owner" | "team" | null>(null); const [customFieldDrafts, setCustomFieldDrafts] = useState>({}); const [customFieldSaving, setCustomFieldSaving] = useState(null); const [editingFieldId, setEditingFieldId] = useState(null); @@ -239,12 +242,13 @@ export default function TicketDetailPage({ setLoading(true); setError(null); - const [ticketRes, txRes, queuesRes, lifecycleRes, usersRes] = await Promise.all([ + const [ticketRes, txRes, queuesRes, lifecycleRes, usersRes, teamsRes] = await Promise.all([ getTicket(id), getTicketTransactions(id), getQueues(), getLifecycles(), getUsers(), + getTeams(), ]); if (ticketRes.error) { @@ -290,6 +294,7 @@ export default function TicketDetailPage({ setError((prev) => prev || usersRes.error); } else { setUsers(usersRes.data ?? []); + setTeams(teamsRes.data ?? []); } setLoading(false); @@ -379,6 +384,29 @@ export default function TicketDetailPage({ } }; + const handleTeamChange = async (teamId: string) => { + if (!ticket || fieldSaving) return; + const nextTeamId = teamId || null; + if (nextTeamId === ticket.team_id) return; + + setFieldSaving("team"); + setFieldError(null); + + const { data, error } = await updateTicket(id, { team_id: nextTeamId }); + + setFieldSaving(null); + + if (error) { + setFieldError(error); + return; + } + + if (data) { + setTicket(data.ticket); + await refreshTransactions(); + } + }; + const handleOwnerChange = async (ownerId: string) => { if (!ticket || fieldSaving) return; const nextOwnerId = ownerId || null; @@ -909,6 +937,25 @@ export default function TicketDetailPage({ +
+
Team
+
+ +
+
diff --git a/web/src/components/app-shell.tsx b/web/src/components/app-shell.tsx index 7388da5..bb4932b 100644 --- a/web/src/components/app-shell.tsx +++ b/web/src/components/app-shell.tsx @@ -8,6 +8,7 @@ import { LayoutGridIcon, PlusIcon, UserIcon, + UsersIcon, InboxIcon, ClockIcon, SettingsIcon, @@ -91,6 +92,7 @@ function SidebarNav() { const [savedViews, setSavedViews] = useState([]); const [dashboards, setDashboards] = useState([]); const [currentUserId, setCurrentUserId] = useState(null); + const [myTeamId, setMyTeamId] = useState(null); const [expanded, setExpanded] = useState>({ dashboards: true, queues: true, @@ -145,6 +147,7 @@ function SidebarNav() { const userTeams = allTeams.filter((t) => (t.members ?? []).some((m) => m.id === myId) ); + setMyTeamId(userTeams[0]?.id ?? null); const teamIds = new Set(userTeams.map((t) => t.id)); const visible = allDashboards.filter((d) => !d.team_id || teamIds.has(d.team_id) @@ -171,6 +174,13 @@ function SidebarNav() { count: counts.my, icon: UserIcon, }, + ...(myTeamId ? [{ + label: "My team's tickets", + href: `/?view=team&team_id=${myTeamId}`, + param: "team", + count: undefined as number | undefined, + icon: UsersIcon, + }] : []), { label: "Unassigned", href: "/?view=unassigned", diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 2d50ae1..9471e5e 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -43,6 +43,7 @@ export async function getTickets(params?: { status?: string; q?: string; owner_id?: string; + team_id?: string; custom_fields?: Record; }): Promise<{ data: Ticket[] | null; error: string | null }> { const sp = new URLSearchParams(); @@ -50,6 +51,7 @@ export async function getTickets(params?: { if (params?.status) sp.set("status", params.status); if (params?.q) sp.set("q", params.q); if (params?.owner_id) sp.set("owner_id", params.owner_id); + if (params?.team_id) sp.set("team_id", params.team_id); if (params?.custom_fields) { for (const [fieldId, value] of Object.entries(params.custom_fields)) { if (value) sp.set(`cf.${fieldId}`, value); @@ -72,7 +74,7 @@ export async function createTicket(data: { return request("/tickets", { method: "POST", body: JSON.stringify(data) }); } -export async function updateTicket(id: number, data: { subject?: string; status?: string; owner_id?: string | null }): Promise<{ data: UpdateResult | null; error: string | null }> { +export async function updateTicket(id: number, data: { subject?: string; status?: string; owner_id?: string | null; team_id?: string | null }): Promise<{ data: UpdateResult | null; error: string | null }> { return request(`/tickets/${id}`, { method: "PATCH", body: JSON.stringify(data) }); } diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 10cfb77..77152cc 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -4,6 +4,7 @@ export interface Ticket { queue_id: string; status: string; owner_id: string | null; + team_id: string | null; creator_id: string; created_at: string; updated_at: string;