feat: queues have default team, tickets inherit it
- team_id on queues table (optional, can be overridden per-ticket) - Ticket creation auto-sets team_id from the queue's default - Queue admin form has team selector (scrip flow node 03) - Queue API (POST/PATCH) accepts team_id No enforcement — just a helpful default. Teams and queues are loosely coupled, not hierarchically locked. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2
drizzle/migrations/0007_flimsy_roughhouse.sql
Normal file
2
drizzle/migrations/0007_flimsy_roughhouse.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "queues" ADD COLUMN "team_id" uuid;--> statement-breakpoint
|
||||
ALTER TABLE "queues" ADD CONSTRAINT "queues_team_id_teams_id_fk" FOREIGN KEY ("team_id") REFERENCES "public"."teams"("id") ON DELETE set null ON UPDATE no action;
|
||||
1329
drizzle/migrations/meta/0007_snapshot.json
Normal file
1329
drizzle/migrations/meta/0007_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -50,6 +50,13 @@
|
||||
"when": 1781008559188,
|
||||
"tag": "0006_nosy_black_queen",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "7",
|
||||
"when": 1781009018666,
|
||||
"tag": "0007_flimsy_roughhouse",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -13,6 +13,7 @@ export const queues = pgTable('queues', {
|
||||
name: text('name').notNull().unique(),
|
||||
description: text('description'),
|
||||
lifecycle_id: uuid('lifecycle_id').references(() => lifecycles.id),
|
||||
team_id: uuid('team_id').references(() => teams.id, { onDelete: 'set null' }),
|
||||
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
|
||||
});
|
||||
|
||||
|
||||
@@ -8,4 +8,5 @@ export const CreateQueueSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
lifecycle_id: z.string().uuid().optional(),
|
||||
team_id: z.string().uuid().nullable().optional(),
|
||||
});
|
||||
|
||||
@@ -23,6 +23,7 @@ export function createQueuesRouter(db: Db): Hono {
|
||||
name: parsed.name,
|
||||
description: parsed.description ?? null,
|
||||
lifecycle_id: parsed.lifecycle_id ?? null,
|
||||
team_id: parsed.team_id ?? null,
|
||||
}).returning();
|
||||
|
||||
if (!queue) {
|
||||
@@ -48,6 +49,7 @@ export function createQueuesRouter(db: Db): Hono {
|
||||
if (body.name !== undefined) updateData.name = String(body.name);
|
||||
if (body.description !== undefined) updateData.description = body.description ? String(body.description) : null;
|
||||
if (body.lifecycle_id !== undefined) updateData.lifecycle_id = body.lifecycle_id || null;
|
||||
if (body.team_id !== undefined) updateData.team_id = body.team_id || null;
|
||||
|
||||
const [updated] = await db.update(queues)
|
||||
.set(updateData)
|
||||
|
||||
@@ -183,6 +183,7 @@ export function createTicketsRouter(db: Db): Hono {
|
||||
queue_id: parsed.queue_id,
|
||||
status: initialStatus,
|
||||
creator_id: creatorId,
|
||||
team_id: (queue as any).team_id ?? null,
|
||||
}).returning();
|
||||
|
||||
if (!ticket) {
|
||||
|
||||
@@ -201,23 +201,27 @@ export default function AdminPage() {
|
||||
function QueuesTab() {
|
||||
const [queues, setQueues] = useState<Queue[]>([]);
|
||||
const [lifecycles, setLifecycles] = useState<Lifecycle[]>([]);
|
||||
const [teams, setTeams] = useState<Team[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [lifecycleId, setLifecycleId] = useState("");
|
||||
const [teamId, setTeamId] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
|
||||
const fetchQueues = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const [queueRes, lifecycleRes] = await Promise.all([getQueues(), getLifecycles()]);
|
||||
const [queueRes, lifecycleRes, teamsRes] = await Promise.all([getQueues(), getLifecycles(), getTeams()]);
|
||||
if (queueRes.error) setError(queueRes.error);
|
||||
else setQueues(queueRes.data ?? []);
|
||||
if (lifecycleRes.error) setError((prev) => prev || lifecycleRes.error);
|
||||
else setLifecycles(lifecycleRes.data ?? []);
|
||||
if (teamsRes.error) setError((prev) => prev || teamsRes.error);
|
||||
else setTeams(teamsRes.data ?? []);
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
@@ -230,6 +234,7 @@ function QueuesTab() {
|
||||
setName("");
|
||||
setDescription("");
|
||||
setLifecycleId("");
|
||||
setTeamId("");
|
||||
setSaveError(null);
|
||||
};
|
||||
|
||||
@@ -238,6 +243,7 @@ function QueuesTab() {
|
||||
setName(queue.name);
|
||||
setDescription(queue.description ?? "");
|
||||
setLifecycleId(queue.lifecycle_id ?? "");
|
||||
setTeamId(queue.team_id ?? "");
|
||||
setSaveError(null);
|
||||
};
|
||||
|
||||
@@ -249,6 +255,7 @@ function QueuesTab() {
|
||||
name: name.trim(),
|
||||
description: description.trim() || null,
|
||||
lifecycle_id: lifecycleId || null,
|
||||
team_id: teamId || null,
|
||||
};
|
||||
const { data, error } = editingId
|
||||
? await updateQueue(editingId, payload)
|
||||
@@ -356,6 +363,19 @@ function QueuesTab() {
|
||||
</Select>
|
||||
</div>
|
||||
</ScripFlowNode>
|
||||
<ScripFlowNode label="03" title="Default team" description="New tickets in this queue inherit this team. Can be changed per-ticket.">
|
||||
<div className="grid gap-1.5">
|
||||
<Select value={teamId || "_none"} onValueChange={(value) => setTeamId(value === "_none" || !value ? "" : value)}>
|
||||
<SelectTrigger id="q-team"><SelectValue placeholder="No default team" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="_none">No default team</SelectItem>
|
||||
{teams.map((team) => (
|
||||
<SelectItem key={team.id} value={team.id}>{team.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</ScripFlowNode>
|
||||
{saveError && <div className="text-sm text-destructive">{saveError}</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -116,11 +116,11 @@ export async function deleteUser(id: string): Promise<{ data: { ok: boolean } |
|
||||
return request<{ ok: boolean }>(`/users/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export async function createQueue(data: { name: string; description?: string | null; lifecycle_id?: string | null }): Promise<{ data: Queue | null; error: string | null }> {
|
||||
export async function createQueue(data: { name: string; description?: string | null; lifecycle_id?: string | null; team_id?: string | null }): Promise<{ data: Queue | null; error: string | null }> {
|
||||
return request<Queue>("/queues", { method: "POST", body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
export async function updateQueue(id: string, data: { name?: string; description?: string | null; lifecycle_id?: string | null }): Promise<{ data: Queue | null; error: string | null }> {
|
||||
export async function updateQueue(id: string, data: { name?: string; description?: string | null; lifecycle_id?: string | null; team_id?: string | null }): Promise<{ data: Queue | null; error: string | null }> {
|
||||
return request<Queue>(`/queues/${id}`, { method: "PATCH", body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface Queue {
|
||||
name: string;
|
||||
description: string | null;
|
||||
lifecycle_id: string | null;
|
||||
team_id: string | null;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
|
||||
Reference in New Issue
Block a user