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,
"tag": "0006_nosy_black_queen",
"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(),
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(),
});

View File

@@ -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(),
});

View File

@@ -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)

View File

@@ -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) {

View File

@@ -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>

View File

@@ -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) });
}

View File

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