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 <noreply@anthropic.com>
This commit is contained in:
2
drizzle/migrations/0006_nosy_black_queen.sql
Normal file
2
drizzle/migrations/0006_nosy_black_queen.sql
Normal file
@@ -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;
|
||||||
1310
drizzle/migrations/meta/0006_snapshot.json
Normal file
1310
drizzle/migrations/meta/0006_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -43,6 +43,13 @@
|
|||||||
"when": 1781004398567,
|
"when": 1781004398567,
|
||||||
"tag": "0005_spotty_leader",
|
"tag": "0005_spotty_leader",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 6,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1781008559188,
|
||||||
|
"tag": "0006_nosy_black_queen",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -29,6 +29,7 @@ export const tickets = pgTable('tickets', {
|
|||||||
queue_id: uuid('queue_id').notNull().references(() => queues.id),
|
queue_id: uuid('queue_id').notNull().references(() => queues.id),
|
||||||
status: text('status').notNull(),
|
status: text('status').notNull(),
|
||||||
owner_id: uuid('owner_id').references(() => users.id),
|
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),
|
creator_id: uuid('creator_id').notNull().references(() => users.id),
|
||||||
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
|
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
|
||||||
updated_at: timestamp('updated_at', { withTimezone: true }).defaultNow(),
|
updated_at: timestamp('updated_at', { withTimezone: true }).defaultNow(),
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export const UpdateTicketSchema = z.object({
|
|||||||
subject: z.string().min(1).optional(),
|
subject: z.string().min(1).optional(),
|
||||||
status: z.string().min(1).optional(),
|
status: z.string().min(1).optional(),
|
||||||
owner_id: z.string().uuid().nullable().optional(),
|
owner_id: z.string().uuid().nullable().optional(),
|
||||||
|
team_id: z.string().uuid().nullable().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const CommentSchema = z.object({
|
export const CommentSchema = z.object({
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import { HTTPException } from 'hono/http-exception';
|
import { HTTPException } from 'hono/http-exception';
|
||||||
import type { Db } from '../db/index.ts';
|
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 { and, eq, asc, or, ilike, isNull, inArray, exists, sql } from 'drizzle-orm';
|
||||||
import { CreateTicketSchema, UpdateTicketSchema, CommentSchema } from '../models/ticket.ts';
|
import { CreateTicketSchema, UpdateTicketSchema, CommentSchema } from '../models/ticket.ts';
|
||||||
import { ScripEngine } from '../scrip/engine.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 queueId = c.req.query('queue_id');
|
||||||
const status = c.req.query('status');
|
const status = c.req.query('status');
|
||||||
const ownerId = c.req.query('owner_id');
|
const ownerId = c.req.query('owner_id');
|
||||||
|
const teamId = c.req.query('team_id');
|
||||||
const query = c.req.query('q')?.trim() ?? '';
|
const query = c.req.query('q')?.trim() ?? '';
|
||||||
const limit = c.req.query('limit') ? Number(c.req.query('limit')) : undefined;
|
const limit = c.req.query('limit') ? Number(c.req.query('limit')) : undefined;
|
||||||
const cfFilters = [...params.entries()]
|
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)
|
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
|
// Text search: push to SQL via ilike on ticket columns + queue name join
|
||||||
if (query) {
|
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
|
// Update the ticket
|
||||||
const updateData: Record<string, unknown> = {};
|
const updateData: Record<string, unknown> = {};
|
||||||
if (parsed.subject) updateData.subject = parsed.subject;
|
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.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();
|
updateData.updated_at = new Date();
|
||||||
|
|
||||||
const [updated] = await db.update(tickets)
|
const [updated] = await db.update(tickets)
|
||||||
|
|||||||
@@ -198,12 +198,14 @@ function TicketWorkbenchContent() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const routeTeamId = searchParams.get("team_id") ?? "";
|
||||||
const [ticketsRes, queuesRes, usersRes, fieldsRes, lifecycleRes] = await Promise.all([
|
const [ticketsRes, queuesRes, usersRes, fieldsRes, lifecycleRes] = await Promise.all([
|
||||||
getTickets({
|
getTickets({
|
||||||
q: searchQuery.trim() || undefined,
|
q: searchQuery.trim() || undefined,
|
||||||
status: apiStatus || undefined,
|
status: apiStatus || undefined,
|
||||||
queue_id: activeQueue || apiQueue || undefined,
|
queue_id: activeQueue || apiQueue || undefined,
|
||||||
owner_id: apiOwner || undefined,
|
owner_id: apiOwner || undefined,
|
||||||
|
team_id: routeTeamId || undefined,
|
||||||
custom_fields: Object.keys(customFieldFilters).length > 0 ? customFieldFilters : undefined,
|
custom_fields: Object.keys(customFieldFilters).length > 0 ? customFieldFilters : undefined,
|
||||||
}),
|
}),
|
||||||
getQueues(),
|
getQueues(),
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
getQueues,
|
getQueues,
|
||||||
getLifecycles,
|
getLifecycles,
|
||||||
getUsers,
|
getUsers,
|
||||||
|
getTeams,
|
||||||
getQueueCustomFields,
|
getQueueCustomFields,
|
||||||
previewTicket,
|
previewTicket,
|
||||||
updateTicket,
|
updateTicket,
|
||||||
@@ -37,6 +38,7 @@ import type {
|
|||||||
Transaction,
|
Transaction,
|
||||||
Queue,
|
Queue,
|
||||||
Lifecycle,
|
Lifecycle,
|
||||||
|
Team,
|
||||||
User,
|
User,
|
||||||
QueueCustomField,
|
QueueCustomField,
|
||||||
PreviewResult,
|
PreviewResult,
|
||||||
@@ -212,6 +214,7 @@ export default function TicketDetailPage({
|
|||||||
const [queue, setQueue] = useState<Queue | null>(null);
|
const [queue, setQueue] = useState<Queue | null>(null);
|
||||||
const [lifecycles, setLifecycles] = useState<Lifecycle[]>([]);
|
const [lifecycles, setLifecycles] = useState<Lifecycle[]>([]);
|
||||||
const [users, setUsers] = useState<User[]>([]);
|
const [users, setUsers] = useState<User[]>([]);
|
||||||
|
const [teams, setTeams] = useState<Team[]>([]);
|
||||||
const [queueFields, setQueueFields] = useState<QueueCustomField[]>([]);
|
const [queueFields, setQueueFields] = useState<QueueCustomField[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -230,7 +233,7 @@ export default function TicketDetailPage({
|
|||||||
const [scripResults, setScripResults] = useState<UpdateResult["scrip_results"] | null>(null);
|
const [scripResults, setScripResults] = useState<UpdateResult["scrip_results"] | null>(null);
|
||||||
const [editingSubject, setEditingSubject] = useState(false);
|
const [editingSubject, setEditingSubject] = useState(false);
|
||||||
const [subjectDraft, setSubjectDraft] = useState("");
|
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<Record<string, string>>({});
|
const [customFieldDrafts, setCustomFieldDrafts] = useState<Record<string, string>>({});
|
||||||
const [customFieldSaving, setCustomFieldSaving] = useState<string | null>(null);
|
const [customFieldSaving, setCustomFieldSaving] = useState<string | null>(null);
|
||||||
const [editingFieldId, setEditingFieldId] = useState<string | null>(null);
|
const [editingFieldId, setEditingFieldId] = useState<string | null>(null);
|
||||||
@@ -239,12 +242,13 @@ export default function TicketDetailPage({
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
const [ticketRes, txRes, queuesRes, lifecycleRes, usersRes] = await Promise.all([
|
const [ticketRes, txRes, queuesRes, lifecycleRes, usersRes, teamsRes] = await Promise.all([
|
||||||
getTicket(id),
|
getTicket(id),
|
||||||
getTicketTransactions(id),
|
getTicketTransactions(id),
|
||||||
getQueues(),
|
getQueues(),
|
||||||
getLifecycles(),
|
getLifecycles(),
|
||||||
getUsers(),
|
getUsers(),
|
||||||
|
getTeams(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (ticketRes.error) {
|
if (ticketRes.error) {
|
||||||
@@ -290,6 +294,7 @@ export default function TicketDetailPage({
|
|||||||
setError((prev) => prev || usersRes.error);
|
setError((prev) => prev || usersRes.error);
|
||||||
} else {
|
} else {
|
||||||
setUsers(usersRes.data ?? []);
|
setUsers(usersRes.data ?? []);
|
||||||
|
setTeams(teamsRes.data ?? []);
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoading(false);
|
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) => {
|
const handleOwnerChange = async (ownerId: string) => {
|
||||||
if (!ticket || fieldSaving) return;
|
if (!ticket || fieldSaving) return;
|
||||||
const nextOwnerId = ownerId || null;
|
const nextOwnerId = ownerId || null;
|
||||||
@@ -909,6 +937,25 @@ export default function TicketDetailPage({
|
|||||||
</select>
|
</select>
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="grid grid-cols-[92px_minmax(0,1fr)] gap-3 border-b border-border px-3 py-2.5">
|
||||||
|
<dt className="text-[11px] font-semibold uppercase text-muted-foreground">Team</dt>
|
||||||
|
<dd className="min-w-0">
|
||||||
|
<select
|
||||||
|
value={ticket.team_id ?? ""}
|
||||||
|
onChange={(event) => void handleTeamChange(event.target.value)}
|
||||||
|
disabled={fieldSaving === "team"}
|
||||||
|
className="h-8 w-full rounded-md border border-input bg-card px-2 text-sm text-foreground outline-none transition-colors focus:border-ring disabled:opacity-60"
|
||||||
|
aria-label="Team"
|
||||||
|
>
|
||||||
|
<option value="">No team</option>
|
||||||
|
{teams.map((team) => (
|
||||||
|
<option key={team.id} value={team.id}>
|
||||||
|
{team.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
<PropertyRow label="Priority" value="Not set" />
|
<PropertyRow label="Priority" value="Not set" />
|
||||||
</dl>
|
</dl>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
LayoutGridIcon,
|
LayoutGridIcon,
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
UserIcon,
|
UserIcon,
|
||||||
|
UsersIcon,
|
||||||
InboxIcon,
|
InboxIcon,
|
||||||
ClockIcon,
|
ClockIcon,
|
||||||
SettingsIcon,
|
SettingsIcon,
|
||||||
@@ -91,6 +92,7 @@ function SidebarNav() {
|
|||||||
const [savedViews, setSavedViews] = useState<SavedView[]>([]);
|
const [savedViews, setSavedViews] = useState<SavedView[]>([]);
|
||||||
const [dashboards, setDashboards] = useState<Dashboard[]>([]);
|
const [dashboards, setDashboards] = useState<Dashboard[]>([]);
|
||||||
const [currentUserId, setCurrentUserId] = useState<string | null>(null);
|
const [currentUserId, setCurrentUserId] = useState<string | null>(null);
|
||||||
|
const [myTeamId, setMyTeamId] = useState<string | null>(null);
|
||||||
const [expanded, setExpanded] = useState<Record<string, boolean>>({
|
const [expanded, setExpanded] = useState<Record<string, boolean>>({
|
||||||
dashboards: true,
|
dashboards: true,
|
||||||
queues: true,
|
queues: true,
|
||||||
@@ -145,6 +147,7 @@ function SidebarNav() {
|
|||||||
const userTeams = allTeams.filter((t) =>
|
const userTeams = allTeams.filter((t) =>
|
||||||
(t.members ?? []).some((m) => m.id === myId)
|
(t.members ?? []).some((m) => m.id === myId)
|
||||||
);
|
);
|
||||||
|
setMyTeamId(userTeams[0]?.id ?? null);
|
||||||
const teamIds = new Set(userTeams.map((t) => t.id));
|
const teamIds = new Set(userTeams.map((t) => t.id));
|
||||||
const visible = allDashboards.filter((d) =>
|
const visible = allDashboards.filter((d) =>
|
||||||
!d.team_id || teamIds.has(d.team_id)
|
!d.team_id || teamIds.has(d.team_id)
|
||||||
@@ -171,6 +174,13 @@ function SidebarNav() {
|
|||||||
count: counts.my,
|
count: counts.my,
|
||||||
icon: UserIcon,
|
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",
|
label: "Unassigned",
|
||||||
href: "/?view=unassigned",
|
href: "/?view=unassigned",
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export async function getTickets(params?: {
|
|||||||
status?: string;
|
status?: string;
|
||||||
q?: string;
|
q?: string;
|
||||||
owner_id?: string;
|
owner_id?: string;
|
||||||
|
team_id?: string;
|
||||||
custom_fields?: Record<string, string>;
|
custom_fields?: Record<string, string>;
|
||||||
}): Promise<{ data: Ticket[] | null; error: string | null }> {
|
}): Promise<{ data: Ticket[] | null; error: string | null }> {
|
||||||
const sp = new URLSearchParams();
|
const sp = new URLSearchParams();
|
||||||
@@ -50,6 +51,7 @@ export async function getTickets(params?: {
|
|||||||
if (params?.status) sp.set("status", params.status);
|
if (params?.status) sp.set("status", params.status);
|
||||||
if (params?.q) sp.set("q", params.q);
|
if (params?.q) sp.set("q", params.q);
|
||||||
if (params?.owner_id) sp.set("owner_id", params.owner_id);
|
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) {
|
if (params?.custom_fields) {
|
||||||
for (const [fieldId, value] of Object.entries(params.custom_fields)) {
|
for (const [fieldId, value] of Object.entries(params.custom_fields)) {
|
||||||
if (value) sp.set(`cf.${fieldId}`, value);
|
if (value) sp.set(`cf.${fieldId}`, value);
|
||||||
@@ -72,7 +74,7 @@ export async function createTicket(data: {
|
|||||||
return request<UpdateResult>("/tickets", { method: "POST", body: JSON.stringify(data) });
|
return request<UpdateResult>("/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<UpdateResult>(`/tickets/${id}`, { method: "PATCH", body: JSON.stringify(data) });
|
return request<UpdateResult>(`/tickets/${id}`, { method: "PATCH", body: JSON.stringify(data) });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export interface Ticket {
|
|||||||
queue_id: string;
|
queue_id: string;
|
||||||
status: string;
|
status: string;
|
||||||
owner_id: string | null;
|
owner_id: string | null;
|
||||||
|
team_id: string | null;
|
||||||
creator_id: string;
|
creator_id: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user