feat(ST-1102): add 4 question-channel MCP tools (M11)
Vier nieuwe tools voor het Claude vraag-antwoord-kanaal: - ask_user_question (write): post een gestructureerde vraag aan de actieve Scrum4Me-gebruiker over een story; default async (returnt direct met question_id + status='open'); optionele wait_seconds (max 600) polt elke 2s tot het antwoord er is of timeout — daarna status='pending' zodat Claude met get_question_answer later kan ophalen - get_question_answer (read): huidige status + antwoord van een eerder gestelde vraag - list_open_questions (read): eigen vragen met status open/answered, max 50, meest recente eerst - cancel_question (write, asker-only): atomic UPDATE WHERE asked_by + status= 'open' zodat alleen eigen open vragen geannuleerd worden Allemaal achter access-check via userCanAccessStory/Product en demo-blok via requireWriteAccess (volgt patroon van create-todo en bestaande log-tools). Submodule vendor/scrum4me bumpt naar Scrum4Me commit 79367dd (M11 ST-1101) — bevat het ClaudeQuestion-model en notify_question_change-trigger waar deze tools tegen werken. scripts/smoke-test.ts: 13 tools verwacht (was 9); list_open_questions toegevoegd als read-tool-coverage. Build + tools/list groen — verdere e2e via MCP Inspector na PR-merge omdat de seed een nieuwe API-token heeft gegenereerd en .env een nieuwe waarde nodig heeft. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
2f82241696
commit
7b955d31ac
9 changed files with 361 additions and 2 deletions
|
|
@ -18,6 +18,10 @@ activity and create todos via native tool calls instead of curl.
|
||||||
| `log_test_result` | Append TEST_RESULT (PASSED/FAILED) | no |
|
| `log_test_result` | Append TEST_RESULT (PASSED/FAILED) | no |
|
||||||
| `log_commit` | Append COMMIT with hash and message | no |
|
| `log_commit` | Append COMMIT with hash and message | no |
|
||||||
| `create_todo` | Add a todo, optionally scoped to a product | no |
|
| `create_todo` | Add a todo, optionally scoped to a product | no |
|
||||||
|
| `ask_user_question` | Post a question to the active user about a story; optional `wait_seconds` (max 600) polls for the answer | no |
|
||||||
|
| `get_question_answer` | Fetch the current status + answer of a previously-asked question | n/a |
|
||||||
|
| `list_open_questions` | List own open/answered questions, most recent first (max 50) | n/a |
|
||||||
|
| `cancel_question` | Cancel an own open question (asker-only) | no |
|
||||||
|
|
||||||
Demo accounts may read but writes return `PERMISSION_DENIED`.
|
Demo accounts may read but writes return `PERMISSION_DENIED`.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,8 @@ model User {
|
||||||
product_members ProductMember[]
|
product_members ProductMember[]
|
||||||
assigned_stories Story[] @relation("StoryAssignee")
|
assigned_stories Story[] @relation("StoryAssignee")
|
||||||
login_pairings LoginPairing[]
|
login_pairings LoginPairing[]
|
||||||
|
asked_questions ClaudeQuestion[] @relation("ClaudeQuestionAsker")
|
||||||
|
answered_questions ClaudeQuestion[] @relation("ClaudeQuestionAnswerer")
|
||||||
|
|
||||||
@@index([active_product_id])
|
@@index([active_product_id])
|
||||||
@@map("users")
|
@@map("users")
|
||||||
|
|
@ -108,6 +110,7 @@ model Product {
|
||||||
todos Todo[]
|
todos Todo[]
|
||||||
members ProductMember[]
|
members ProductMember[]
|
||||||
active_for_users User[] @relation("UserActiveProduct")
|
active_for_users User[] @relation("UserActiveProduct")
|
||||||
|
claude_questions ClaudeQuestion[]
|
||||||
|
|
||||||
@@unique([user_id, name])
|
@@unique([user_id, name])
|
||||||
@@unique([user_id, code])
|
@@unique([user_id, code])
|
||||||
|
|
@ -154,6 +157,7 @@ model Story {
|
||||||
updated_at DateTime @updatedAt
|
updated_at DateTime @updatedAt
|
||||||
logs StoryLog[]
|
logs StoryLog[]
|
||||||
tasks Task[]
|
tasks Task[]
|
||||||
|
claude_questions ClaudeQuestion[]
|
||||||
|
|
||||||
@@unique([product_id, code])
|
@@unique([product_id, code])
|
||||||
@@index([pbi_id, priority, sort_order])
|
@@index([pbi_id, priority, sort_order])
|
||||||
|
|
@ -208,6 +212,7 @@ model Task {
|
||||||
status TaskStatus @default(TO_DO)
|
status TaskStatus @default(TO_DO)
|
||||||
created_at DateTime @default(now())
|
created_at DateTime @default(now())
|
||||||
updated_at DateTime @updatedAt
|
updated_at DateTime @updatedAt
|
||||||
|
claude_questions ClaudeQuestion[]
|
||||||
|
|
||||||
@@index([story_id, priority, sort_order])
|
@@index([story_id, priority, sort_order])
|
||||||
@@index([sprint_id, status])
|
@@index([sprint_id, status])
|
||||||
|
|
@ -263,3 +268,29 @@ model LoginPairing {
|
||||||
@@index([status, expires_at])
|
@@index([status, expires_at])
|
||||||
@@map("login_pairings")
|
@@map("login_pairings")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model ClaudeQuestion {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
story Story @relation(fields: [story_id], references: [id], onDelete: Cascade)
|
||||||
|
story_id String
|
||||||
|
task Task? @relation(fields: [task_id], references: [id], onDelete: SetNull)
|
||||||
|
task_id String?
|
||||||
|
product Product @relation(fields: [product_id], references: [id], onDelete: Cascade)
|
||||||
|
product_id String // gedenormaliseerd uit story.product_id voor SSE-filter
|
||||||
|
asker User @relation("ClaudeQuestionAsker", fields: [asked_by], references: [id])
|
||||||
|
asked_by String // user_id van token-houder (= Claude-token)
|
||||||
|
question String @db.Text
|
||||||
|
options Json? // string[] voor multi-choice; null voor free-text
|
||||||
|
status String // 'open' | 'answered' | 'cancelled' | 'expired'
|
||||||
|
answer String? @db.Text
|
||||||
|
answerer User? @relation("ClaudeQuestionAnswerer", fields: [answered_by], references: [id])
|
||||||
|
answered_by String?
|
||||||
|
answered_at DateTime?
|
||||||
|
created_at DateTime @default(now())
|
||||||
|
expires_at DateTime // ingesteld door MCP-tool, default now() + 24h
|
||||||
|
|
||||||
|
@@index([story_id, status])
|
||||||
|
@@index([product_id, status])
|
||||||
|
@@index([status, expires_at])
|
||||||
|
@@map("claude_questions")
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,7 @@ async function main() {
|
||||||
const tools = await client.listTools()
|
const tools = await client.listTools()
|
||||||
log(
|
log(
|
||||||
'tools/list',
|
'tools/list',
|
||||||
tools.tools.length === 9,
|
tools.tools.length === 13,
|
||||||
`${tools.tools.length} tools: ${tools.tools.map((t) => t.name).join(', ')}`,
|
`${tools.tools.length} tools: ${tools.tools.map((t) => t.name).join(', ')}`,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -75,6 +75,14 @@ async function main() {
|
||||||
log('get_claude_context', !ctx.isError, ctx.text)
|
log('get_claude_context', !ctx.isError, ctx.text)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// list_open_questions (M11 — read-only, geen write nodig voor smoke-test)
|
||||||
|
const openQs = await callTool(client, 'list_open_questions')
|
||||||
|
log('list_open_questions', !openQs.isError, openQs.text)
|
||||||
|
if (!openQs.isError) {
|
||||||
|
const parsed = JSON.parse(openQs.text) as { count: number }
|
||||||
|
log('list_open_questions.shape', typeof parsed.count === 'number', `count=${parsed.count}`)
|
||||||
|
}
|
||||||
|
|
||||||
// prompts/list
|
// prompts/list
|
||||||
const prompts = await client.listPrompts()
|
const prompts = await client.listPrompts()
|
||||||
log(
|
log(
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,10 @@ import { registerLogImplementationTool } from './tools/log-implementation.js'
|
||||||
import { registerLogTestResultTool } from './tools/log-test-result.js'
|
import { registerLogTestResultTool } from './tools/log-test-result.js'
|
||||||
import { registerLogCommitTool } from './tools/log-commit.js'
|
import { registerLogCommitTool } from './tools/log-commit.js'
|
||||||
import { registerCreateTodoTool } from './tools/create-todo.js'
|
import { registerCreateTodoTool } from './tools/create-todo.js'
|
||||||
|
import { registerAskUserQuestionTool } from './tools/ask-user-question.js'
|
||||||
|
import { registerGetQuestionAnswerTool } from './tools/get-question-answer.js'
|
||||||
|
import { registerListOpenQuestionsTool } from './tools/list-open-questions.js'
|
||||||
|
import { registerCancelQuestionTool } from './tools/cancel-question.js'
|
||||||
import { registerImplementNextStoryPrompt } from './prompts/implement-next-story.js'
|
import { registerImplementNextStoryPrompt } from './prompts/implement-next-story.js'
|
||||||
|
|
||||||
const VERSION = '0.1.0'
|
const VERSION = '0.1.0'
|
||||||
|
|
@ -33,6 +37,10 @@ async function main() {
|
||||||
registerLogTestResultTool(server)
|
registerLogTestResultTool(server)
|
||||||
registerLogCommitTool(server)
|
registerLogCommitTool(server)
|
||||||
registerCreateTodoTool(server)
|
registerCreateTodoTool(server)
|
||||||
|
registerAskUserQuestionTool(server)
|
||||||
|
registerGetQuestionAnswerTool(server)
|
||||||
|
registerListOpenQuestionsTool(server)
|
||||||
|
registerCancelQuestionTool(server)
|
||||||
registerImplementNextStoryPrompt(server)
|
registerImplementNextStoryPrompt(server)
|
||||||
|
|
||||||
const transport = new StdioServerTransport()
|
const transport = new StdioServerTransport()
|
||||||
|
|
|
||||||
124
src/tools/ask-user-question.ts
Normal file
124
src/tools/ask-user-question.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
// ST-1102: ask_user_question — Claude vraagt aan de actieve gebruiker via een
|
||||||
|
// gestructureerde vraag in claude_questions. De Postgres-trigger emit op
|
||||||
|
// scrum4me_changes; de Scrum4Me-app toont een notificatie-badge en de gebruiker
|
||||||
|
// antwoordt in de UI. Met optionele `wait_seconds` polt deze tool intern op
|
||||||
|
// het antwoord; default async (returnt direct met question_id).
|
||||||
|
|
||||||
|
import { z } from 'zod'
|
||||||
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||||
|
import { prisma } from '../prisma.js'
|
||||||
|
import { requireWriteAccess } from '../auth.js'
|
||||||
|
import { userCanAccessStory } from '../access.js'
|
||||||
|
import { toolError, toolJson, withToolErrors } from '../errors.js'
|
||||||
|
|
||||||
|
const PENDING_TTL_HOURS = 24
|
||||||
|
const POLL_INTERVAL_MS = 2_000
|
||||||
|
const MAX_WAIT_SECONDS = 600
|
||||||
|
|
||||||
|
const inputSchema = z.object({
|
||||||
|
story_id: z.string().min(1),
|
||||||
|
question: z.string().min(1).max(4_000),
|
||||||
|
options: z.array(z.string().min(1)).max(8).optional(),
|
||||||
|
task_id: z.string().min(1).optional(),
|
||||||
|
wait_seconds: z.number().int().min(0).max(MAX_WAIT_SECONDS).optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
function summarize(q: {
|
||||||
|
id: string
|
||||||
|
status: string
|
||||||
|
question: string
|
||||||
|
options: unknown
|
||||||
|
answer: string | null
|
||||||
|
answered_by: string | null
|
||||||
|
answered_at: Date | null
|
||||||
|
expires_at: Date
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
question_id: q.id,
|
||||||
|
status: q.status,
|
||||||
|
question: q.question,
|
||||||
|
options: q.options,
|
||||||
|
answer: q.answer,
|
||||||
|
answered_by: q.answered_by,
|
||||||
|
answered_at: q.answered_at?.toISOString() ?? null,
|
||||||
|
expires_at: q.expires_at.toISOString(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerAskUserQuestionTool(server: McpServer) {
|
||||||
|
server.registerTool(
|
||||||
|
'ask_user_question',
|
||||||
|
{
|
||||||
|
title: 'Ask user question',
|
||||||
|
description:
|
||||||
|
'Post a question to the active Scrum4Me user about a story Claude is implementing. ' +
|
||||||
|
'Returns immediately with status="open" by default; pass `wait_seconds` (max 600) to ' +
|
||||||
|
'poll internally and return the answer as soon as the user submits it. Forbidden for ' +
|
||||||
|
'demo accounts.',
|
||||||
|
inputSchema,
|
||||||
|
},
|
||||||
|
async ({ story_id, question, options, task_id, wait_seconds }) =>
|
||||||
|
withToolErrors(async () => {
|
||||||
|
const auth = await requireWriteAccess()
|
||||||
|
if (!(await userCanAccessStory(story_id, auth.userId))) {
|
||||||
|
return toolError(`Story ${story_id} not found or not accessible`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const story = await prisma.story.findUnique({
|
||||||
|
where: { id: story_id },
|
||||||
|
select: { product_id: true },
|
||||||
|
})
|
||||||
|
if (!story) {
|
||||||
|
return toolError(`Story ${story_id} not found`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (task_id) {
|
||||||
|
const task = await prisma.task.findFirst({
|
||||||
|
where: { id: task_id, story_id },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
if (!task) {
|
||||||
|
return toolError(`Task ${task_id} does not belong to story ${story_id}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = await prisma.claudeQuestion.create({
|
||||||
|
data: {
|
||||||
|
story_id,
|
||||||
|
task_id: task_id ?? null,
|
||||||
|
product_id: story.product_id,
|
||||||
|
asked_by: auth.userId,
|
||||||
|
question,
|
||||||
|
// Prisma's `Json?`-veld accepteert geen `null`-literal in `data`;
|
||||||
|
// door undefined fields uit te sluiten laten we DB-default kicken.
|
||||||
|
...(options !== undefined ? { options } : {}),
|
||||||
|
status: 'open',
|
||||||
|
expires_at: new Date(Date.now() + PENDING_TTL_HOURS * 60 * 60 * 1000),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Async-mode (default): return direct.
|
||||||
|
if (!wait_seconds || wait_seconds === 0) {
|
||||||
|
return toolJson(summarize(created))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync-mode: poll tot status verandert of timeout.
|
||||||
|
const deadline = Date.now() + wait_seconds * 1000
|
||||||
|
let current = created
|
||||||
|
while (current.status === 'open' && Date.now() < deadline) {
|
||||||
|
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS))
|
||||||
|
const refreshed = await prisma.claudeQuestion.findUnique({
|
||||||
|
where: { id: created.id },
|
||||||
|
})
|
||||||
|
if (!refreshed) break
|
||||||
|
current = refreshed
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eindstatus: answered/cancelled/expired → return met antwoord; nog open → 'pending'
|
||||||
|
if (current.status === 'open') {
|
||||||
|
return toolJson({ ...summarize(current), status: 'pending' })
|
||||||
|
}
|
||||||
|
return toolJson(summarize(current))
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
52
src/tools/cancel-question.ts
Normal file
52
src/tools/cancel-question.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
// ST-1102: cancel_question — alleen de asker (Claude) annuleert een eigen open
|
||||||
|
// vraag, bv. wanneer hij in de loop van het werk de oplossing zelf vindt en
|
||||||
|
// het antwoord niet meer relevant is.
|
||||||
|
|
||||||
|
import { z } from 'zod'
|
||||||
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||||
|
import { prisma } from '../prisma.js'
|
||||||
|
import { requireWriteAccess } from '../auth.js'
|
||||||
|
import { toolError, toolJson, withToolErrors } from '../errors.js'
|
||||||
|
|
||||||
|
const inputSchema = z.object({
|
||||||
|
question_id: z.string().min(1),
|
||||||
|
})
|
||||||
|
|
||||||
|
export function registerCancelQuestionTool(server: McpServer) {
|
||||||
|
server.registerTool(
|
||||||
|
'cancel_question',
|
||||||
|
{
|
||||||
|
title: 'Cancel question',
|
||||||
|
description:
|
||||||
|
'Cancel an own open Claude question (only the asker may cancel). ' +
|
||||||
|
'Forbidden for demo accounts.',
|
||||||
|
inputSchema,
|
||||||
|
},
|
||||||
|
async ({ question_id }) =>
|
||||||
|
withToolErrors(async () => {
|
||||||
|
const auth = await requireWriteAccess()
|
||||||
|
|
||||||
|
// Atomic: alleen open vragen van deze asker mogen cancelled worden.
|
||||||
|
const result = await prisma.claudeQuestion.updateMany({
|
||||||
|
where: {
|
||||||
|
id: question_id,
|
||||||
|
asked_by: auth.userId,
|
||||||
|
status: 'open',
|
||||||
|
},
|
||||||
|
data: { status: 'cancelled' },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (result.count !== 1) {
|
||||||
|
// Disambigueer: bestaat de vraag voor deze asker?
|
||||||
|
const exists = await prisma.claudeQuestion.findFirst({
|
||||||
|
where: { id: question_id, asked_by: auth.userId },
|
||||||
|
select: { status: true },
|
||||||
|
})
|
||||||
|
if (!exists) return toolError(`Question ${question_id} not found or not yours`)
|
||||||
|
return toolError(`Question ${question_id} is already ${exists.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return toolJson({ question_id, status: 'cancelled' })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
65
src/tools/get-question-answer.ts
Normal file
65
src/tools/get-question-answer.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
// ST-1102: get_question_answer — vraag de huidige status + antwoord op van een
|
||||||
|
// eerder gestelde vraag. Bedoeld voor Claude om in een latere sessie het
|
||||||
|
// resultaat op te pikken (voor wanneer ask_user_question async werd gebruikt).
|
||||||
|
|
||||||
|
import { z } from 'zod'
|
||||||
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||||
|
import { prisma } from '../prisma.js'
|
||||||
|
import { getAuth } from '../auth.js'
|
||||||
|
import { userCanAccessProduct } from '../access.js'
|
||||||
|
import { toolError, toolJson, withToolErrors } from '../errors.js'
|
||||||
|
|
||||||
|
const inputSchema = z.object({
|
||||||
|
question_id: z.string().min(1),
|
||||||
|
})
|
||||||
|
|
||||||
|
export function registerGetQuestionAnswerTool(server: McpServer) {
|
||||||
|
server.registerTool(
|
||||||
|
'get_question_answer',
|
||||||
|
{
|
||||||
|
title: 'Get question answer',
|
||||||
|
description:
|
||||||
|
'Fetch the current status and (if available) answer for a previously-asked Claude question. ' +
|
||||||
|
'Use this when ask_user_question was called with wait_seconds=0 (or timed out) and you want ' +
|
||||||
|
'to check whether the user has answered.',
|
||||||
|
inputSchema,
|
||||||
|
},
|
||||||
|
async ({ question_id }) =>
|
||||||
|
withToolErrors(async () => {
|
||||||
|
const auth = await getAuth()
|
||||||
|
const q = await prisma.claudeQuestion.findUnique({
|
||||||
|
where: { id: question_id },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
product_id: true,
|
||||||
|
story_id: true,
|
||||||
|
task_id: true,
|
||||||
|
status: true,
|
||||||
|
question: true,
|
||||||
|
options: true,
|
||||||
|
answer: true,
|
||||||
|
answered_by: true,
|
||||||
|
answered_at: true,
|
||||||
|
expires_at: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!q) return toolError(`Question ${question_id} not found`)
|
||||||
|
if (!(await userCanAccessProduct(q.product_id, auth.userId))) {
|
||||||
|
return toolError(`Question ${question_id} not accessible`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return toolJson({
|
||||||
|
question_id: q.id,
|
||||||
|
status: q.status,
|
||||||
|
story_id: q.story_id,
|
||||||
|
task_id: q.task_id,
|
||||||
|
question: q.question,
|
||||||
|
options: q.options,
|
||||||
|
answer: q.answer,
|
||||||
|
answered_by: q.answered_by,
|
||||||
|
answered_at: q.answered_at?.toISOString() ?? null,
|
||||||
|
expires_at: q.expires_at.toISOString(),
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
67
src/tools/list-open-questions.ts
Normal file
67
src/tools/list-open-questions.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
// ST-1102: list_open_questions — toont vragen die de tokengebruiker (Claude)
|
||||||
|
// zelf heeft gesteld, status open of answered. Bedoeld als check-up bij begin
|
||||||
|
// van een nieuwe sessie: zijn er antwoorden binnengekomen op eerdere vragen?
|
||||||
|
|
||||||
|
import { z } from 'zod'
|
||||||
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||||
|
import { prisma } from '../prisma.js'
|
||||||
|
import { getAuth } from '../auth.js'
|
||||||
|
import { toolJson, withToolErrors } from '../errors.js'
|
||||||
|
|
||||||
|
const MAX_RESULTS = 50
|
||||||
|
|
||||||
|
const inputSchema = z.object({
|
||||||
|
story_id: z.string().min(1).optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export function registerListOpenQuestionsTool(server: McpServer) {
|
||||||
|
server.registerTool(
|
||||||
|
'list_open_questions',
|
||||||
|
{
|
||||||
|
title: 'List open questions',
|
||||||
|
description:
|
||||||
|
'List Claude questions asked by the current token, status open or answered, ' +
|
||||||
|
`most recent first (max ${MAX_RESULTS}). Optionally filter by story_id.`,
|
||||||
|
inputSchema,
|
||||||
|
},
|
||||||
|
async ({ story_id }) =>
|
||||||
|
withToolErrors(async () => {
|
||||||
|
const auth = await getAuth()
|
||||||
|
const rows = await prisma.claudeQuestion.findMany({
|
||||||
|
where: {
|
||||||
|
asked_by: auth.userId,
|
||||||
|
status: { in: ['open', 'answered'] },
|
||||||
|
...(story_id ? { story_id } : {}),
|
||||||
|
},
|
||||||
|
orderBy: { created_at: 'desc' },
|
||||||
|
take: MAX_RESULTS,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
story_id: true,
|
||||||
|
task_id: true,
|
||||||
|
status: true,
|
||||||
|
question: true,
|
||||||
|
answer: true,
|
||||||
|
created_at: true,
|
||||||
|
answered_at: true,
|
||||||
|
expires_at: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return toolJson({
|
||||||
|
count: rows.length,
|
||||||
|
questions: rows.map((q) => ({
|
||||||
|
question_id: q.id,
|
||||||
|
story_id: q.story_id,
|
||||||
|
task_id: q.task_id,
|
||||||
|
status: q.status,
|
||||||
|
question: q.question,
|
||||||
|
answer: q.answer,
|
||||||
|
created_at: q.created_at.toISOString(),
|
||||||
|
answered_at: q.answered_at?.toISOString() ?? null,
|
||||||
|
expires_at: q.expires_at.toISOString(),
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
2
vendor/scrum4me
vendored
2
vendor/scrum4me
vendored
|
|
@ -1 +1 @@
|
||||||
Subproject commit 74616432d25dfbee706de1eb80b338e3d7433764
|
Subproject commit 79367dda7bf10e1a0c95786d861bcb08c6674a99
|
||||||
Loading…
Add table
Add a link
Reference in a new issue