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:
Gjermund Høsøien Wiggen
2026-06-09 14:47:20 +02:00
parent 3d7ba0d6a7
commit 4e285f8c4d
10 changed files with 1367 additions and 3 deletions

View 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;

File diff suppressed because it is too large Load Diff

View File

@@ -50,6 +50,13 @@
"when": 1781008559188, "when": 1781008559188,
"tag": "0006_nosy_black_queen", "tag": "0006_nosy_black_queen",
"breakpoints": true "breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1781009018666,
"tag": "0007_flimsy_roughhouse",
"breakpoints": true
} }
] ]
} }

View File

@@ -13,6 +13,7 @@ export const queues = pgTable('queues', {
name: text('name').notNull().unique(), name: text('name').notNull().unique(),
description: text('description'), description: text('description'),
lifecycle_id: uuid('lifecycle_id').references(() => lifecycles.id), 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(), created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
}); });

View File

@@ -8,4 +8,5 @@ export const CreateQueueSchema = z.object({
name: z.string().min(1), name: z.string().min(1),
description: z.string().optional(), description: z.string().optional(),
lifecycle_id: z.string().uuid().optional(), lifecycle_id: z.string().uuid().optional(),
team_id: z.string().uuid().nullable().optional(),
}); });

View File

@@ -23,6 +23,7 @@ export function createQueuesRouter(db: Db): Hono {
name: parsed.name, name: parsed.name,
description: parsed.description ?? null, description: parsed.description ?? null,
lifecycle_id: parsed.lifecycle_id ?? null, lifecycle_id: parsed.lifecycle_id ?? null,
team_id: parsed.team_id ?? null,
}).returning(); }).returning();
if (!queue) { if (!queue) {
@@ -48,6 +49,7 @@ export function createQueuesRouter(db: Db): Hono {
if (body.name !== undefined) updateData.name = String(body.name); if (body.name !== undefined) updateData.name = String(body.name);
if (body.description !== undefined) updateData.description = body.description ? String(body.description) : null; 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.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) const [updated] = await db.update(queues)
.set(updateData) .set(updateData)

View File

@@ -183,6 +183,7 @@ export function createTicketsRouter(db: Db): Hono {
queue_id: parsed.queue_id, queue_id: parsed.queue_id,
status: initialStatus, status: initialStatus,
creator_id: creatorId, creator_id: creatorId,
team_id: (queue as any).team_id ?? null,
}).returning(); }).returning();
if (!ticket) { if (!ticket) {

View File

@@ -201,23 +201,27 @@ export default function AdminPage() {
function QueuesTab() { function QueuesTab() {
const [queues, setQueues] = useState<Queue[]>([]); const [queues, setQueues] = useState<Queue[]>([]);
const [lifecycles, setLifecycles] = useState<Lifecycle[]>([]); const [lifecycles, setLifecycles] = useState<Lifecycle[]>([]);
const [teams, setTeams] = useState<Team[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [editingId, setEditingId] = useState<string | null>(null); const [editingId, setEditingId] = useState<string | null>(null);
const [name, setName] = useState(""); const [name, setName] = useState("");
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
const [lifecycleId, setLifecycleId] = useState(""); const [lifecycleId, setLifecycleId] = useState("");
const [teamId, setTeamId] = useState("");
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null); const [saveError, setSaveError] = useState<string | null>(null);
const fetchQueues = useCallback(async () => { const fetchQueues = useCallback(async () => {
setLoading(true); setLoading(true);
setError(null); 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); if (queueRes.error) setError(queueRes.error);
else setQueues(queueRes.data ?? []); else setQueues(queueRes.data ?? []);
if (lifecycleRes.error) setError((prev) => prev || lifecycleRes.error); if (lifecycleRes.error) setError((prev) => prev || lifecycleRes.error);
else setLifecycles(lifecycleRes.data ?? []); else setLifecycles(lifecycleRes.data ?? []);
if (teamsRes.error) setError((prev) => prev || teamsRes.error);
else setTeams(teamsRes.data ?? []);
setLoading(false); setLoading(false);
}, []); }, []);
@@ -230,6 +234,7 @@ function QueuesTab() {
setName(""); setName("");
setDescription(""); setDescription("");
setLifecycleId(""); setLifecycleId("");
setTeamId("");
setSaveError(null); setSaveError(null);
}; };
@@ -238,6 +243,7 @@ function QueuesTab() {
setName(queue.name); setName(queue.name);
setDescription(queue.description ?? ""); setDescription(queue.description ?? "");
setLifecycleId(queue.lifecycle_id ?? ""); setLifecycleId(queue.lifecycle_id ?? "");
setTeamId(queue.team_id ?? "");
setSaveError(null); setSaveError(null);
}; };
@@ -249,6 +255,7 @@ function QueuesTab() {
name: name.trim(), name: name.trim(),
description: description.trim() || null, description: description.trim() || null,
lifecycle_id: lifecycleId || null, lifecycle_id: lifecycleId || null,
team_id: teamId || null,
}; };
const { data, error } = editingId const { data, error } = editingId
? await updateQueue(editingId, payload) ? await updateQueue(editingId, payload)
@@ -356,6 +363,19 @@ function QueuesTab() {
</Select> </Select>
</div> </div>
</ScripFlowNode> </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>} {saveError && <div className="text-sm text-destructive">{saveError}</div>}
</div> </div>
</div> </div>

View File

@@ -116,11 +116,11 @@ export async function deleteUser(id: string): Promise<{ data: { ok: boolean } |
return request<{ ok: boolean }>(`/users/${id}`, { method: "DELETE" }); 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) }); 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) }); return request<Queue>(`/queues/${id}`, { method: "PATCH", body: JSON.stringify(data) });
} }

View File

@@ -18,6 +18,7 @@ export interface Queue {
name: string; name: string;
description: string | null; description: string | null;
lifecycle_id: string | null; lifecycle_id: string | null;
team_id: string | null;
} }
export interface User { export interface User {