diff --git a/api/src/models/comment.ts b/api/src/models/comment.ts index 55e72df2a..e12c262a0 100644 --- a/api/src/models/comment.ts +++ b/api/src/models/comment.ts @@ -5,3 +5,7 @@ export const Comment = z.object({ comment: z.string() }); export type Comment = z.infer; + +export interface UpdateComment { + comment: string; +} diff --git a/api/src/models/ticket-comment.ts b/api/src/models/ticket-comment.ts new file mode 100644 index 000000000..91c3963d7 --- /dev/null +++ b/api/src/models/ticket-comment.ts @@ -0,0 +1,38 @@ +import { z } from 'zod'; + +export const TicketComment = z.object({ + ticket_comment_id: z.string().uuid(), + ticket_id: z.string().uuid(), + user_identifier: z.string(), + create_date: z.string(), + comment: z.string() +}); + +export type TicketComment = z.infer; + +export const CreateTicketCommentRequest = z.object({ + comment: z.string().min(1).max(3000) +}); + +export type CreateTicketCommentRequest = z.infer; + +export const UpdateTicketCommentRequest = z.object({ + comment: z.string().min(1).max(3000) +}); + +export type UpdateTicketCommentRequest = z.infer; + +export const CreateTicketComment = z.object({ + ticketId: z.string().uuid(), + comment: z.string().min(1).max(3000) +}); + +export type CreateTicketComment = z.infer; + +export const UpdateTicketComment = z.object({ + ticketId: z.string().uuid(), + ticketCommentId: z.string().uuid(), + comment: z.string().min(1).max(3000) +}); + +export type UpdateTicketComment = z.infer; diff --git a/api/src/models/ticket-reference.ts b/api/src/models/ticket-reference.ts new file mode 100644 index 000000000..2ef438fb7 --- /dev/null +++ b/api/src/models/ticket-reference.ts @@ -0,0 +1,43 @@ +import { z } from 'zod'; + +export const TicketRelationshipType = z.enum([ + 'blocks', + 'blocked_by', + 'duplicates', + 'duplicate_of', + 'relates_to', + 'resolves', + 'resolved_by' +]); + +export type TicketRelationshipType = z.infer; + +export const TicketReference = z.object({ + ticket_reference_id: z.string().uuid(), + source_ticket_id: z.string().uuid(), + source_ticket_slug: z.string().regex(/^\d{8}$/), + source_ticket_subject: z.string(), + target_ticket_id: z.string().uuid(), + target_ticket_slug: z.string().regex(/^\d{8}$/), + target_ticket_subject: z.string(), + relationship: TicketRelationshipType, + user_identifier: z.string(), + create_date: z.string() +}); + +export type TicketReference = z.infer; + +export const CreateTicketReference = z.object({ + source_ticket_id: z.string().uuid(), + target_ticket_id: z.string().uuid(), + relationship: TicketRelationshipType +}); + +export type CreateTicketReference = z.infer; + +export const CreateTicketReferenceRequest = z.object({ + target_ticket_id: z.string().uuid(), + relationship: TicketRelationshipType +}); + +export type CreateTicketReferenceRequest = z.infer; diff --git a/api/src/models/ticket-status.ts b/api/src/models/ticket-status.ts new file mode 100644 index 000000000..92f7661ae --- /dev/null +++ b/api/src/models/ticket-status.ts @@ -0,0 +1,11 @@ +import { z } from 'zod'; + +export const TicketStatus = z.object({ + ticket_status_id: z.string().uuid(), + ticket_id: z.string().uuid(), + user_identifier: z.string(), + create_date: z.string(), + status: z.enum(['open', 'closed']) +}); + +export type TicketStatus = z.infer; diff --git a/api/src/models/ticket.ts b/api/src/models/ticket.ts new file mode 100644 index 000000000..9f62e58fc --- /dev/null +++ b/api/src/models/ticket.ts @@ -0,0 +1,71 @@ +import { z } from 'zod'; +import { TicketComment } from './ticket-comment'; +import { TicketReference } from './ticket-reference'; +import { TicketStatus as TicketStatusRecord } from './ticket-status'; + +export const TicketPriority = z.enum(['low', 'medium', 'high', 'critical']); +export type TicketPriority = z.infer; + +export const TicketStatus = z.enum(['open', 'closed']); +export type TicketStatus = z.infer; + +export interface TicketFilters { + team_id?: string; + status?: TicketStatus; +} + +export const Ticket = z.object({ + ticket_id: z.string().uuid(), + ticket_slug: z.string().regex(/^\d{8}$/), + subject: z.string(), + description: z.string().nullable(), + team_id: z.string().uuid(), + create_date: z.string(), + priority: TicketPriority, + status: TicketStatus +}); + +export type Ticket = z.infer; + +export const TicketSlug = Ticket.pick({ ticket_slug: true }); +export type TicketSlug = z.infer; + +export const CreateTicketRequest = z.object({ + subject: z.string(), + description: z.string().nullable(), + priority: TicketPriority +}); + +export type CreateTicketRequest = z.infer; + +export type CreateTicketPayload = CreateTicketRequest & { + team_id: string; + ticket_slug: string; +}; + +export const UpdateTicketRequest = z.object({ + subject: z.string().optional(), + description: z.string().nullable().optional(), + priority: TicketPriority.optional(), + status: TicketStatus.optional() +}); + +export type UpdateTicketRequest = z.infer; + +export const UpdateTicketStatusRequest = z.object({ + status: TicketStatus +}); + +export type UpdateTicketStatusRequest = z.infer; + +export * from './ticket-comment'; +export * from './ticket-reference'; +export * from './ticket-status'; + +export const TicketWithHistory = Ticket.extend({ + statuses: z.array(TicketStatusRecord), + comments: z.array(TicketComment), + references: z.array(TicketReference) +}); + +export type TicketWithHistory = z.infer; diff --git a/api/src/openapi/schemas/ticket.ts b/api/src/openapi/schemas/ticket.ts new file mode 100644 index 000000000..a8f8fbbe6 --- /dev/null +++ b/api/src/openapi/schemas/ticket.ts @@ -0,0 +1,198 @@ +import { OpenAPIV3 } from 'openapi-types'; +import { paginationResponseSchema } from './pagination'; + +const TicketPriorityEnum = ['low', 'medium', 'high', 'critical']; +const TicketStatusEnum = ['open', 'closed']; + +export const TicketCommentSchema: OpenAPIV3.SchemaObject = { + type: 'object', + required: ['ticket_comment_id', 'ticket_id', 'user_identifier', 'create_date', 'comment'], + properties: { + ticket_comment_id: { type: 'string', format: 'uuid' }, + ticket_id: { type: 'string', format: 'uuid' }, + user_identifier: { type: 'string' }, + create_date: { type: 'string', format: 'date-time' }, + comment: { type: 'string' } + } +}; + +export const TicketReferenceSchema: OpenAPIV3.SchemaObject = { + type: 'object', + required: [ + 'ticket_reference_id', + 'source_ticket_id', + 'source_ticket_slug', + 'source_ticket_subject', + 'target_ticket_id', + 'target_ticket_slug', + 'target_ticket_subject', + 'relationship', + 'user_identifier', + 'create_date' + ], + properties: { + ticket_reference_id: { type: 'string', format: 'uuid' }, + source_ticket_id: { type: 'string', format: 'uuid' }, + source_ticket_slug: { type: 'string', minLength: 8, maxLength: 8, pattern: String.raw`^\d{8}$` }, + source_ticket_subject: { type: 'string', maxLength: 100 }, + target_ticket_id: { type: 'string', format: 'uuid' }, + target_ticket_slug: { type: 'string', minLength: 8, maxLength: 8, pattern: String.raw`^\d{8}$` }, + target_ticket_subject: { type: 'string', maxLength: 100 }, + relationship: { + type: 'string', + enum: ['blocks', 'blocked_by', 'duplicates', 'duplicate_of', 'relates_to', 'resolves', 'resolved_by'] + }, + user_identifier: { type: 'string' }, + create_date: { type: 'string', format: 'date-time' } + } +}; + +export const TicketSchema: OpenAPIV3.SchemaObject = { + type: 'object', + required: ['ticket_id', 'ticket_slug', 'subject', 'team_id', 'create_date', 'priority', 'status'], + properties: { + ticket_id: { type: 'string', format: 'uuid' }, + ticket_slug: { type: 'string', minLength: 8, maxLength: 8, pattern: String.raw`^\d{8}$` }, + subject: { type: 'string', maxLength: 100 }, + description: { type: 'string', maxLength: 2000, nullable: true }, + team_id: { type: 'string', format: 'uuid' }, + create_date: { type: 'string', format: 'date-time' }, + priority: { type: 'string', enum: TicketPriorityEnum }, + status: { type: 'string', enum: TicketStatusEnum } + } +}; + +export const TicketWithHistorySchema: OpenAPIV3.SchemaObject = { + type: 'object', + required: [ + 'ticket_id', + 'ticket_slug', + 'subject', + 'team_id', + 'create_date', + 'priority', + 'status', + 'statuses', + 'comments', + 'references' + ], + properties: { + ticket_id: { type: 'string', format: 'uuid' }, + ticket_slug: { type: 'string', minLength: 8, maxLength: 8, pattern: String.raw`^\d{8}$` }, + subject: { type: 'string', maxLength: 100 }, + description: { type: 'string', maxLength: 2000, nullable: true }, + team_id: { type: 'string', format: 'uuid' }, + create_date: { type: 'string', format: 'date-time' }, + priority: { type: 'string', enum: TicketPriorityEnum }, + status: { type: 'string', enum: TicketStatusEnum }, + statuses: { + description: 'Status change history for the ticket.', + type: 'array', + items: { + type: 'object', + required: ['ticket_status_id', 'ticket_id', 'user_identifier', 'create_date', 'status'], + properties: { + ticket_status_id: { type: 'string', format: 'uuid' }, + ticket_id: { type: 'string', format: 'uuid' }, + user_identifier: { type: 'string' }, + create_date: { type: 'string', format: 'date-time' }, + status: { type: 'string', enum: TicketStatusEnum } + } + } + }, + comments: { + type: 'array', + items: TicketCommentSchema + }, + references: { + type: 'array', + items: TicketReferenceSchema + } + } +}; + +export const CreateTicketRequestSchema: OpenAPIV3.SchemaObject = { + type: 'object', + additionalProperties: false, + required: ['subject', 'description', 'priority'], + properties: { + subject: { type: 'string', maxLength: 100 }, + description: { type: 'string', maxLength: 2000, nullable: true }, + priority: { type: 'string', enum: TicketPriorityEnum } + } +}; + +export const UpdateTicketRequestSchema: OpenAPIV3.SchemaObject = { + type: 'object', + additionalProperties: false, + properties: { + subject: { type: 'string', maxLength: 100 }, + description: { type: 'string', maxLength: 2000, nullable: true }, + priority: { type: 'string', enum: TicketPriorityEnum }, + status: { type: 'string', enum: TicketStatusEnum } + } +}; + +export const UpdateTicketStatusRequestSchema: OpenAPIV3.SchemaObject = { + type: 'object', + additionalProperties: false, + required: ['status'], + properties: { + status: { type: 'string', enum: TicketStatusEnum } + } +}; + +export const CreateTicketCommentRequestSchema: OpenAPIV3.SchemaObject = { + type: 'object', + additionalProperties: false, + required: ['comment'], + properties: { + comment: { type: 'string', minLength: 1, maxLength: 3000 } + } +}; + +export const UpdateTicketCommentRequestSchema: OpenAPIV3.SchemaObject = { + type: 'object', + additionalProperties: false, + required: ['comment'], + properties: { + comment: { type: 'string', minLength: 1, maxLength: 3000 } + } +}; + +export const CreateTicketReferenceRequestSchema: OpenAPIV3.SchemaObject = { + type: 'object', + additionalProperties: false, + required: ['target_ticket_id', 'relationship'], + properties: { + target_ticket_id: { type: 'string', format: 'uuid' }, + relationship: { + type: 'string', + enum: ['blocks', 'blocked_by', 'duplicates', 'duplicate_of', 'relates_to', 'resolves', 'resolved_by'] + } + } +}; + +export const TicketStatusSchema: OpenAPIV3.SchemaObject = { + type: 'object', + required: ['ticket_status_id', 'ticket_id', 'user_identifier', 'create_date', 'status'], + properties: { + ticket_status_id: { type: 'string', format: 'uuid' }, + ticket_id: { type: 'string', format: 'uuid' }, + user_identifier: { type: 'string' }, + create_date: { type: 'string', format: 'date-time' }, + status: { type: 'string', enum: TicketStatusEnum } + } +}; + +export const TicketListResponseSchema: OpenAPIV3.SchemaObject = { + type: 'object', + required: ['tickets', 'pagination'], + properties: { + tickets: { + type: 'array', + items: TicketSchema + }, + pagination: paginationResponseSchema + } +}; diff --git a/api/src/paths/tickets/index.test.ts b/api/src/paths/tickets/index.test.ts new file mode 100644 index 000000000..a03558080 --- /dev/null +++ b/api/src/paths/tickets/index.test.ts @@ -0,0 +1,121 @@ +import chai, { expect } from 'chai'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import * as db from '../../database/db'; +import { Ticket } from '../../models/ticket'; +import { TicketService } from '../../services/ticket-service'; +import { getMockDBConnection, getRequestHandlerMocks } from '../../__mocks__/db'; +import { createTicket, getTickets } from './index'; + +chai.use(sinonChai); + +describe('paths/tickets', () => { + const mockTicket: Ticket = { + ticket_id: '11111111-1111-1111-1111-111111111111', + ticket_slug: '04900001', + subject: 'A ticket', + description: 'desc', + team_id: '22222222-2222-2222-2222-222222222222', + create_date: '2026-02-25T00:00:00.000Z', + priority: 'medium', + status: 'open' + }; + + afterEach(() => { + sinon.restore(); + }); + + it('POST createTicket returns 201 with created ticket', async () => { + const mockDBConnection = getMockDBConnection({ + commit: sinon.stub(), + rollback: sinon.stub(), + release: sinon.stub() + }); + sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + sinon.stub(TicketService.prototype, 'createTicket').resolves(mockTicket); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + mockReq.body = { subject: 'A ticket', description: null, priority: 'medium' }; + + await createTicket()(mockReq, mockRes, mockNext); + + expect(mockRes.statusValue).to.equal(201); + expect(mockRes.jsonValue).to.eql(mockTicket); + }); + + it('GET getTickets returns paginated response', async () => { + const mockDBConnection = getMockDBConnection({ + commit: sinon.stub(), + rollback: sinon.stub(), + release: sinon.stub() + }); + sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + const listStub = sinon.stub(TicketService.prototype, 'getTickets').resolves([mockTicket]); + const countStub = sinon.stub(TicketService.prototype, 'getTicketsCount').resolves(21); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + mockReq.query = { team_id: mockTicket.team_id, status: 'open', page: '2', limit: '10' }; + + await getTickets()(mockReq, mockRes, mockNext); + + expect(listStub).to.have.been.calledWith( + { team_id: mockTicket.team_id, status: 'open' }, + { + page: 2, + limit: 10, + sort: undefined, + order: undefined + } + ); + expect(countStub).to.have.been.calledWith({ team_id: mockTicket.team_id, status: 'open' }); + expect(mockRes.statusValue).to.equal(200); + expect(mockRes.jsonValue).to.eql({ + tickets: [mockTicket], + pagination: { + total: 21, + per_page: 10, + current_page: 2, + last_page: 3, + sort: undefined, + order: undefined + } + }); + }); + + it('GET getTickets returns all tickets when team_id is omitted', async () => { + const mockDBConnection = getMockDBConnection({ + commit: sinon.stub(), + rollback: sinon.stub(), + release: sinon.stub() + }); + sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + const listStub = sinon.stub(TicketService.prototype, 'getTickets').resolves([mockTicket]); + const countStub = sinon.stub(TicketService.prototype, 'getTicketsCount').resolves(1); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + mockReq.query = { page: '1', limit: '10' }; + const noFilters = { team_id: undefined, status: undefined }; + + await getTickets()(mockReq, mockRes, mockNext); + + expect(listStub).to.have.been.calledWith(noFilters, { + page: 1, + limit: 10, + sort: undefined, + order: undefined + }); + expect(countStub).to.have.been.calledWith(noFilters); + expect(mockRes.statusValue).to.equal(200); + expect(mockRes.jsonValue).to.eql({ + tickets: [mockTicket], + pagination: { + total: 1, + per_page: 10, + current_page: 1, + last_page: 1, + sort: undefined, + order: undefined + } + }); + }); +}); diff --git a/api/src/paths/tickets/index.ts b/api/src/paths/tickets/index.ts new file mode 100644 index 000000000..c5a156249 --- /dev/null +++ b/api/src/paths/tickets/index.ts @@ -0,0 +1,169 @@ +import { RequestHandler } from 'express'; +import { Operation } from 'express-openapi'; +import { SYSTEM_ROLE } from '../../constants/roles'; +import { getDBConnection } from '../../database/db'; +import { defaultErrorResponses } from '../../openapi/schemas/http-responses'; +import { paginationRequestQueryParamSchema } from '../../openapi/schemas/pagination'; +import { CreateTicketRequestSchema, TicketListResponseSchema, TicketSchema } from '../../openapi/schemas/ticket'; +import { authorizeRequestHandler } from '../../request-handlers/security/authorization'; +import { TicketService } from '../../services/ticket-service'; +import { getLogger } from '../../utils/logger'; +import { + ensureCompletePaginationOptions, + makePaginationOptionsFromRequest, + makePaginationResponse +} from '../../utils/pagination'; + +const defaultLog = getLogger('paths/tickets'); + +export const POST: Operation = [ + authorizeRequestHandler(() => ({ + and: [ + { + validSystemRoles: [SYSTEM_ROLE.SYSTEM_ADMIN], + discriminator: 'SystemRole' + } + ] + })), + createTicket() +]; + +export const GET: Operation = [ + authorizeRequestHandler(() => ({ + and: [ + { + validSystemRoles: [SYSTEM_ROLE.SYSTEM_ADMIN], + discriminator: 'SystemRole' + } + ] + })), + getTickets() +]; + +POST.apiDoc = { + description: 'Create a ticket', + tags: ['tickets'], + security: [ + { + Bearer: [] + } + ], + requestBody: { + content: { + 'application/json': { + schema: CreateTicketRequestSchema + } + } + }, + responses: { + 201: { + description: 'Ticket created successfully', + content: { + 'application/json': { + schema: TicketSchema + } + } + }, + ...defaultErrorResponses + } +}; + +GET.apiDoc = { + description: 'List tickets by team ID, optionally filtered by status', + tags: ['tickets'], + security: [ + { + Bearer: [] + } + ], + parameters: [ + { + in: 'query', + name: 'team_id', + required: false, + schema: { + type: 'string', + format: 'uuid' + }, + description: 'Optional team filter. If omitted, returns tickets across all teams.' + }, + { + in: 'query', + name: 'status', + required: false, + schema: { + type: 'string', + enum: ['open', 'closed'] + } + }, + ...paginationRequestQueryParamSchema + ], + responses: { + 200: { + description: 'Tickets retrieved successfully', + content: { + 'application/json': { + schema: TicketListResponseSchema + } + } + }, + ...defaultErrorResponses + } +}; + +export function createTicket(): RequestHandler { + return async (req, res) => { + const connection = getDBConnection(req.keycloak_token); + + try { + await connection.open(); + + const ticketService = new TicketService(connection); + const ticket = await ticketService.createTicket(req.body); + + await connection.commit(); + + return res.status(201).json(ticket); + } catch (error) { + defaultLog.error({ label: 'createTicket', message: 'error', error }); + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + }; +} + +export function getTickets(): RequestHandler { + return async (req, res) => { + const connection = getDBConnection(req.keycloak_token); + + try { + await connection.open(); + + const ticketService = new TicketService(connection); + const teamId = req.query.team_id as string | undefined; + const status = req.query.status as 'open' | 'closed' | undefined; + const pagination = makePaginationOptionsFromRequest(req); + const filters = { team_id: teamId, status }; + + const [tickets, count] = await Promise.all([ + ticketService.getTickets(filters, ensureCompletePaginationOptions(pagination)), + ticketService.getTicketsCount(filters) + ]); + + await connection.commit(); + + return res.status(200).json({ + tickets, + pagination: makePaginationResponse(count, pagination) + }); + } catch (error) { + defaultLog.error({ label: 'getTickets', message: 'error', error }); + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + }; +} diff --git a/api/src/paths/tickets/{ticketId}/comment/index.test.ts b/api/src/paths/tickets/{ticketId}/comment/index.test.ts new file mode 100644 index 000000000..58cb05cdb --- /dev/null +++ b/api/src/paths/tickets/{ticketId}/comment/index.test.ts @@ -0,0 +1,51 @@ +import chai, { expect } from 'chai'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import * as db from '../../../../database/db'; +import { TicketComment } from '../../../../models/ticket-comment'; +import { TicketCommentService } from '../../../../services/ticket-comment-service'; +import { getMockDBConnection, getRequestHandlerMocks } from '../../../../__mocks__/db'; +import { createTicketComment } from './index'; + +chai.use(sinonChai); + +describe('paths/tickets/{ticketId}/comment', () => { + const ticketId = '11111111-1111-1111-1111-111111111111'; + + afterEach(() => { + sinon.restore(); + }); + + it('POST creates a ticket comment', async () => { + const createdComment: TicketComment = { + ticket_comment_id: '33333333-3333-3333-3333-333333333333', + ticket_id: ticketId, + user_identifier: 'Sarah', + create_date: '2026-02-25T00:00:00.000Z', + comment: 'A comment' + }; + + const mockDBConnection = getMockDBConnection({ + commit: sinon.stub(), + rollback: sinon.stub(), + release: sinon.stub() + }); + sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + const createCommentStub = sinon + .stub(TicketCommentService.prototype, 'createTicketComment') + .resolves(createdComment); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + mockReq.params = { ticketId }; + mockReq.body = { comment: 'A comment' }; + + await createTicketComment()(mockReq, mockRes, mockNext); + + expect(createCommentStub).to.have.been.calledWith({ + ticketId, + comment: 'A comment' + }); + expect(mockRes.statusValue).to.equal(201); + expect(mockRes.jsonValue).to.eql(createdComment); + }); +}); diff --git a/api/src/paths/tickets/{ticketId}/comment/index.ts b/api/src/paths/tickets/{ticketId}/comment/index.ts new file mode 100644 index 000000000..599eb53d4 --- /dev/null +++ b/api/src/paths/tickets/{ticketId}/comment/index.ts @@ -0,0 +1,91 @@ +import { RequestHandler } from 'express'; +import { Operation } from 'express-openapi'; +import { SYSTEM_ROLE } from '../../../../constants/roles'; +import { getDBConnection } from '../../../../database/db'; +import { type CreateTicketCommentRequest } from '../../../../models/ticket-comment'; +import { defaultErrorResponses } from '../../../../openapi/schemas/http-responses'; +import { CreateTicketCommentRequestSchema, TicketCommentSchema } from '../../../../openapi/schemas/ticket'; +import { authorizeRequestHandler } from '../../../../request-handlers/security/authorization'; +import { TicketCommentService } from '../../../../services/ticket-comment-service'; +import { getLogger } from '../../../../utils/logger'; + +const defaultLog = getLogger('paths/tickets/{ticketId}/comment'); + +export const POST: Operation = [ + authorizeRequestHandler(() => ({ + and: [ + { + validSystemRoles: [SYSTEM_ROLE.SYSTEM_ADMIN], + discriminator: 'SystemRole' + } + ] + })), + createTicketComment() +]; + +POST.apiDoc = { + description: 'Add a comment to a ticket timeline', + tags: ['tickets'], + security: [ + { + Bearer: [] + } + ], + parameters: [ + { + in: 'path', + name: 'ticketId', + required: true, + schema: { + type: 'string', + format: 'uuid' + }, + description: 'Ticket UUID.' + } + ], + requestBody: { + content: { + 'application/json': { + schema: CreateTicketCommentRequestSchema + } + } + }, + responses: { + 201: { + description: 'Ticket comment created successfully', + content: { + 'application/json': { + schema: TicketCommentSchema + } + } + }, + ...defaultErrorResponses + } +}; + +export function createTicketComment(): RequestHandler { + return async (req, res) => { + const connection = getDBConnection(req.keycloak_token); + + try { + await connection.open(); + + const ticketCommentService = new TicketCommentService(connection); + const commentPayload = req.body as CreateTicketCommentRequest; + const createdComment = await ticketCommentService.createTicketComment({ + ticketId: req.params.ticketId, + comment: commentPayload.comment + }); + + await connection.commit(); + + return res.status(201).json(createdComment); + } catch (error) { + defaultLog.error({ label: 'createTicketComment', message: 'error', error }); + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + }; +} diff --git a/api/src/paths/tickets/{ticketId}/comment/{ticketCommentId}/index.test.ts b/api/src/paths/tickets/{ticketId}/comment/{ticketCommentId}/index.test.ts new file mode 100644 index 000000000..9daf393bb --- /dev/null +++ b/api/src/paths/tickets/{ticketId}/comment/{ticketCommentId}/index.test.ts @@ -0,0 +1,73 @@ +import chai, { expect } from 'chai'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import * as db from '../../../../../database/db'; +import { TicketComment } from '../../../../../models/ticket-comment'; +import { TicketCommentService } from '../../../../../services/ticket-comment-service'; +import { getMockDBConnection, getRequestHandlerMocks } from '../../../../../__mocks__/db'; +import { deleteTicketComment, updateTicketComment } from './index'; + +chai.use(sinonChai); + +describe('paths/tickets/{ticketId}/comment/{ticketCommentId}', () => { + const ticketId = '11111111-1111-1111-1111-111111111111'; + const ticketCommentId = '33333333-3333-3333-3333-333333333333'; + + afterEach(() => { + sinon.restore(); + }); + + it('PUT updates a ticket comment', async () => { + const mockDBConnection = getMockDBConnection({ + commit: sinon.stub(), + rollback: sinon.stub(), + release: sinon.stub() + }); + sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + + const updatedComment: TicketComment = { + ticket_comment_id: ticketCommentId, + ticket_id: ticketId, + user_identifier: 'Sarah', + create_date: '2026-02-25T00:00:00.000Z', + comment: 'Updated comment' + }; + + const updateCommentStub = sinon + .stub(TicketCommentService.prototype, 'updateTicketComment') + .resolves(updatedComment); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + mockReq.params = { ticketId, ticketCommentId }; + mockReq.body = { comment: 'Updated comment' }; + + await updateTicketComment()(mockReq, mockRes, mockNext); + + expect(updateCommentStub).to.have.been.calledWith({ + ticketId, + ticketCommentId, + comment: 'Updated comment' + }); + expect(mockRes.statusValue).to.equal(200); + expect(mockRes.jsonValue).to.eql(updatedComment); + }); + + it('DELETE removes a ticket comment', async () => { + const mockDBConnection = getMockDBConnection({ + commit: sinon.stub(), + rollback: sinon.stub(), + release: sinon.stub() + }); + sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + + const deleteCommentStub = sinon.stub(TicketCommentService.prototype, 'deleteTicketCommentByTicketId').resolves(); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + mockReq.params = { ticketId, ticketCommentId }; + + await deleteTicketComment()(mockReq, mockRes, mockNext); + + expect(deleteCommentStub).to.have.been.calledWith(ticketId, ticketCommentId); + expect(mockRes.statusValue).to.equal(204); + }); +}); diff --git a/api/src/paths/tickets/{ticketId}/comment/{ticketCommentId}/index.ts b/api/src/paths/tickets/{ticketId}/comment/{ticketCommentId}/index.ts new file mode 100644 index 000000000..c0e38353e --- /dev/null +++ b/api/src/paths/tickets/{ticketId}/comment/{ticketCommentId}/index.ts @@ -0,0 +1,175 @@ +import { RequestHandler } from 'express'; +import { Operation } from 'express-openapi'; +import { SYSTEM_ROLE } from '../../../../../constants/roles'; +import { getDBConnection } from '../../../../../database/db'; +import { type UpdateTicketCommentRequest } from '../../../../../models/ticket'; +import { defaultErrorResponses } from '../../../../../openapi/schemas/http-responses'; +import { TicketCommentSchema, UpdateTicketCommentRequestSchema } from '../../../../../openapi/schemas/ticket'; +import { authorizeRequestHandler } from '../../../../../request-handlers/security/authorization'; +import { TicketCommentService } from '../../../../../services/ticket-comment-service'; +import { getLogger } from '../../../../../utils/logger'; + +const defaultLog = getLogger('paths/tickets/{ticketId}/comment/{ticketCommentId}'); + +export const PUT: Operation = [ + authorizeRequestHandler(() => ({ + and: [ + { + validSystemRoles: [SYSTEM_ROLE.SYSTEM_ADMIN], + discriminator: 'SystemRole' + } + ] + })), + updateTicketComment() +]; + +export const DELETE: Operation = [ + authorizeRequestHandler(() => ({ + and: [ + { + validSystemRoles: [SYSTEM_ROLE.SYSTEM_ADMIN], + discriminator: 'SystemRole' + } + ] + })), + deleteTicketComment() +]; + +PUT.apiDoc = { + description: 'Update a ticket comment by id', + tags: ['tickets'], + security: [ + { + Bearer: [] + } + ], + parameters: [ + { + in: 'path', + name: 'ticketId', + required: true, + schema: { + type: 'string', + format: 'uuid' + }, + description: 'Ticket UUID.' + }, + { + in: 'path', + name: 'ticketCommentId', + required: true, + schema: { + type: 'string', + format: 'uuid' + }, + description: 'Ticket comment UUID.' + } + ], + requestBody: { + content: { + 'application/json': { + schema: UpdateTicketCommentRequestSchema + } + } + }, + responses: { + 200: { + description: 'Ticket comment updated successfully', + content: { + 'application/json': { + schema: TicketCommentSchema + } + } + }, + ...defaultErrorResponses + } +}; + +DELETE.apiDoc = { + description: 'Delete a ticket comment by id', + tags: ['tickets'], + security: [ + { + Bearer: [] + } + ], + parameters: [ + { + in: 'path', + name: 'ticketId', + required: true, + schema: { + type: 'string', + format: 'uuid' + }, + description: 'Ticket UUID.' + }, + { + in: 'path', + name: 'ticketCommentId', + required: true, + schema: { + type: 'string', + format: 'uuid' + }, + description: 'Ticket comment UUID.' + } + ], + responses: { + 204: { + description: 'Ticket comment deleted successfully' + }, + ...defaultErrorResponses + } +}; + +export function updateTicketComment(): RequestHandler { + return async (req, res) => { + const connection = getDBConnection(req.keycloak_token); + + try { + await connection.open(); + + const ticketCommentService = new TicketCommentService(connection); + const payload = req.body as UpdateTicketCommentRequest; + const updatedComment = await ticketCommentService.updateTicketComment({ + ticketId: req.params.ticketId, + ticketCommentId: req.params.ticketCommentId, + comment: payload.comment + }); + + await connection.commit(); + + return res.status(200).json(updatedComment); + } catch (error) { + defaultLog.error({ label: 'updateTicketComment', message: 'error', error }); + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + }; +} + +export function deleteTicketComment(): RequestHandler { + return async (req, res) => { + const connection = getDBConnection(req.keycloak_token); + + try { + await connection.open(); + + const ticketCommentService = new TicketCommentService(connection); + await ticketCommentService.deleteTicketCommentByTicketId(req.params.ticketId, req.params.ticketCommentId); + + await connection.commit(); + + return res.status(204).send(); + } catch (error) { + defaultLog.error({ label: 'deleteTicketComment', message: 'error', error }); + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + }; +} diff --git a/api/src/paths/tickets/{ticketId}/index.test.ts b/api/src/paths/tickets/{ticketId}/index.test.ts new file mode 100644 index 000000000..248b9eb93 --- /dev/null +++ b/api/src/paths/tickets/{ticketId}/index.test.ts @@ -0,0 +1,98 @@ +import chai, { expect } from 'chai'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import * as db from '../../../database/db'; +import { Ticket, TicketWithHistory } from '../../../models/ticket'; +import { TicketService } from '../../../services/ticket-service'; +import { getMockDBConnection, getRequestHandlerMocks } from '../../../__mocks__/db'; +import { deleteTicket, getTicket, putTicket } from './index'; + +chai.use(sinonChai); + +describe('paths/tickets/{ticketId}', () => { + const mockTicket: Ticket = { + ticket_id: '11111111-1111-1111-1111-111111111111', + ticket_slug: '04900001', + subject: 'A ticket', + description: 'desc', + team_id: '22222222-2222-2222-2222-222222222222', + create_date: '2026-02-25T00:00:00.000Z', + priority: 'medium', + status: 'open' + }; + const mockTicketWithHistory: TicketWithHistory = { + ...mockTicket, + statuses: [ + { + ticket_status_id: '33333333-3333-3333-3333-333333333333', + ticket_id: mockTicket.ticket_id, + user_identifier: 'Sarah', + create_date: '2026-02-25T00:00:00.000Z', + status: 'open' + } + ], + comments: [], + references: [] + }; + + afterEach(() => { + sinon.restore(); + }); + + it('GET returns ticket by id', async () => { + const mockDBConnection = getMockDBConnection({ + commit: sinon.stub(), + rollback: sinon.stub(), + release: sinon.stub() + }); + sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + sinon.stub(TicketService.prototype, 'getTicket').resolves(mockTicketWithHistory); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + mockReq.params = { ticketId: mockTicket.ticket_id }; + + await getTicket()(mockReq, mockRes, mockNext); + + expect(mockRes.statusValue).to.equal(200); + expect(mockRes.jsonValue).to.eql(mockTicketWithHistory); + }); + + it('PUT updates ticket', async () => { + const updated = { ...mockTicket, subject: 'updated' }; + const mockDBConnection = getMockDBConnection({ + commit: sinon.stub(), + rollback: sinon.stub(), + release: sinon.stub() + }); + sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + const updateStub = sinon.stub(TicketService.prototype, 'updateTicket').resolves(updated); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + mockReq.params = { ticketId: mockTicket.ticket_id }; + mockReq.body = { subject: 'updated' }; + + await putTicket()(mockReq, mockRes, mockNext); + + expect(updateStub).to.have.been.calledWith(mockTicket.ticket_id, { subject: 'updated' }); + expect(mockRes.statusValue).to.equal(200); + expect(mockRes.jsonValue).to.eql(updated); + }); + + it('DELETE removes ticket', async () => { + const mockDBConnection = getMockDBConnection({ + commit: sinon.stub(), + rollback: sinon.stub(), + release: sinon.stub() + }); + sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + const deleteStub = sinon.stub(TicketService.prototype, 'deleteTicket').resolves(); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + mockReq.params = { ticketId: mockTicket.ticket_id }; + + await deleteTicket()(mockReq, mockRes, mockNext); + + expect(deleteStub).to.have.been.calledWith(mockTicket.ticket_id); + expect(mockRes.statusValue).to.equal(204); + }); +}); diff --git a/api/src/paths/tickets/{ticketId}/index.ts b/api/src/paths/tickets/{ticketId}/index.ts new file mode 100644 index 000000000..cf72d1692 --- /dev/null +++ b/api/src/paths/tickets/{ticketId}/index.ts @@ -0,0 +1,215 @@ +import { RequestHandler } from 'express'; +import { Operation } from 'express-openapi'; +import { SYSTEM_ROLE } from '../../../constants/roles'; +import { getDBConnection } from '../../../database/db'; +import { defaultErrorResponses } from '../../../openapi/schemas/http-responses'; +import { TicketSchema, TicketWithHistorySchema, UpdateTicketRequestSchema } from '../../../openapi/schemas/ticket'; +import { authorizeRequestHandler } from '../../../request-handlers/security/authorization'; +import { TicketService } from '../../../services/ticket-service'; +import { getLogger } from '../../../utils/logger'; + +const defaultLog = getLogger('paths/tickets/{ticketId}'); + +export const GET: Operation = [ + authorizeRequestHandler(() => ({ + and: [ + { + validSystemRoles: [SYSTEM_ROLE.SYSTEM_ADMIN], + discriminator: 'SystemRole' + } + ] + })), + getTicket() +]; +export const PUT: Operation = [ + authorizeRequestHandler(() => ({ + and: [ + { + validSystemRoles: [SYSTEM_ROLE.SYSTEM_ADMIN], + discriminator: 'SystemRole' + } + ] + })), + putTicket() +]; +export const DELETE: Operation = [ + authorizeRequestHandler(() => ({ + and: [ + { + validSystemRoles: [SYSTEM_ROLE.SYSTEM_ADMIN], + discriminator: 'SystemRole' + } + ] + })), + deleteTicket() +]; + +GET.apiDoc = { + description: 'Get ticket details by ticket ID', + tags: ['tickets'], + security: [ + { + Bearer: [] + } + ], + parameters: [ + { + in: 'path', + name: 'ticketId', + required: true, + schema: { + type: 'string', + format: 'uuid' + }, + description: 'Ticket UUID.' + } + ], + responses: { + 200: { + description: 'Ticket retrieved successfully', + content: { + 'application/json': { + schema: TicketWithHistorySchema + } + } + }, + ...defaultErrorResponses + } +}; + +PUT.apiDoc = { + description: 'Replace editable ticket fields including status', + tags: ['tickets'], + security: [ + { + Bearer: [] + } + ], + parameters: [ + { + in: 'path', + name: 'ticketId', + required: true, + schema: { + type: 'string', + format: 'uuid' + }, + description: 'Ticket UUID.' + } + ], + requestBody: { + content: { + 'application/json': { + schema: UpdateTicketRequestSchema + } + } + }, + responses: { + 200: { + description: 'Ticket updated successfully', + content: { + 'application/json': { + schema: TicketSchema + } + } + }, + ...defaultErrorResponses + } +}; + +DELETE.apiDoc = { + description: 'Delete a ticket by ticket ID', + tags: ['tickets'], + security: [ + { + Bearer: [] + } + ], + parameters: [ + { + in: 'path', + name: 'ticketId', + required: true, + schema: { + type: 'string', + format: 'uuid' + }, + description: 'Ticket UUID.' + } + ], + responses: { + 204: { + description: 'Ticket deleted successfully' + }, + ...defaultErrorResponses + } +}; + +export function getTicket(): RequestHandler { + return async (req, res) => { + const connection = getDBConnection(req.keycloak_token); + + try { + await connection.open(); + + const ticketService = new TicketService(connection); + const ticket = await ticketService.getTicket(req.params.ticketId); + + await connection.commit(); + + return res.status(200).json(ticket); + } catch (error) { + defaultLog.error({ label: 'getTicket', message: 'error', error }); + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + }; +} + +export function putTicket(): RequestHandler { + return async (req, res) => { + const connection = getDBConnection(req.keycloak_token); + + try { + await connection.open(); + + const ticketService = new TicketService(connection); + const ticket = await ticketService.updateTicket(req.params.ticketId, req.body); + + await connection.commit(); + + return res.status(200).json(ticket); + } catch (error) { + defaultLog.error({ label: 'putTicket', message: 'error', error }); + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + }; +} + +export function deleteTicket(): RequestHandler { + return async (req, res) => { + const connection = getDBConnection(req.keycloak_token); + + try { + await connection.open(); + + const ticketService = new TicketService(connection); + await ticketService.deleteTicket(req.params.ticketId); + + await connection.commit(); + + return res.status(204).send(); + } catch (error) { + defaultLog.error({ label: 'deleteTicket', message: 'error', error }); + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + }; +} diff --git a/api/src/paths/tickets/{ticketId}/reference/index.test.ts b/api/src/paths/tickets/{ticketId}/reference/index.test.ts new file mode 100644 index 000000000..d9bc5884a --- /dev/null +++ b/api/src/paths/tickets/{ticketId}/reference/index.test.ts @@ -0,0 +1,55 @@ +import chai, { expect } from 'chai'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import * as db from '../../../../database/db'; +import { TicketReference } from '../../../../models/ticket-reference'; +import { TicketService } from '../../../../services/ticket-service'; +import { getMockDBConnection, getRequestHandlerMocks } from '../../../../__mocks__/db'; +import { createTicketReference } from './index'; + +chai.use(sinonChai); + +describe('paths/tickets/{ticketId}/reference', () => { + const sourceTicketId = '11111111-1111-1111-1111-111111111111'; + const targetTicketId = '22222222-2222-2222-2222-222222222222'; + + afterEach(() => { + sinon.restore(); + }); + + it('POST creates a ticket reference', async () => { + const createdReference: TicketReference = { + ticket_reference_id: '33333333-3333-3333-3333-333333333333', + source_ticket_id: sourceTicketId, + source_ticket_slug: '04900001', + source_ticket_subject: 'Source ticket', + target_ticket_id: targetTicketId, + target_ticket_slug: '04900002', + target_ticket_subject: 'Target ticket', + relationship: 'relates_to', + user_identifier: 'Sarah', + create_date: '2026-02-25T00:00:00.000Z' + }; + + const mockDBConnection = getMockDBConnection({ + commit: sinon.stub(), + rollback: sinon.stub(), + release: sinon.stub() + }); + sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + const createReferenceStub = sinon.stub(TicketService.prototype, 'createTicketReference').resolves(createdReference); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + mockReq.params = { ticketId: sourceTicketId }; + mockReq.body = { target_ticket_id: targetTicketId, relationship: 'relates_to' }; + + await createTicketReference()(mockReq, mockRes, mockNext); + + expect(createReferenceStub).to.have.been.calledWith(sourceTicketId, { + target_ticket_id: targetTicketId, + relationship: 'relates_to' + }); + expect(mockRes.statusValue).to.equal(201); + expect(mockRes.jsonValue).to.eql(createdReference); + }); +}); diff --git a/api/src/paths/tickets/{ticketId}/reference/index.ts b/api/src/paths/tickets/{ticketId}/reference/index.ts new file mode 100644 index 000000000..d51947021 --- /dev/null +++ b/api/src/paths/tickets/{ticketId}/reference/index.ts @@ -0,0 +1,88 @@ +import { RequestHandler } from 'express'; +import { Operation } from 'express-openapi'; +import { SYSTEM_ROLE } from '../../../../constants/roles'; +import { getDBConnection } from '../../../../database/db'; +import { type CreateTicketReferenceRequest } from '../../../../models/ticket-reference'; +import { defaultErrorResponses } from '../../../../openapi/schemas/http-responses'; +import { CreateTicketReferenceRequestSchema, TicketReferenceSchema } from '../../../../openapi/schemas/ticket'; +import { authorizeRequestHandler } from '../../../../request-handlers/security/authorization'; +import { TicketService } from '../../../../services/ticket-service'; +import { getLogger } from '../../../../utils/logger'; + +const defaultLog = getLogger('paths/tickets/{ticketId}/reference'); + +export const POST: Operation = [ + authorizeRequestHandler(() => ({ + and: [ + { + validSystemRoles: [SYSTEM_ROLE.SYSTEM_ADMIN], + discriminator: 'SystemRole' + } + ] + })), + createTicketReference() +]; + +POST.apiDoc = { + description: 'Add a reference from a ticket to another ticket', + tags: ['tickets'], + security: [ + { + Bearer: [] + } + ], + parameters: [ + { + in: 'path', + name: 'ticketId', + required: true, + schema: { + type: 'string', + format: 'uuid' + }, + description: 'Source ticket UUID.' + } + ], + requestBody: { + content: { + 'application/json': { + schema: CreateTicketReferenceRequestSchema + } + } + }, + responses: { + 201: { + description: 'Ticket reference created successfully', + content: { + 'application/json': { + schema: TicketReferenceSchema + } + } + }, + ...defaultErrorResponses + } +}; + +export function createTicketReference(): RequestHandler { + return async (req, res) => { + const connection = getDBConnection(req.keycloak_token); + + try { + await connection.open(); + + const ticketService = new TicketService(connection); + const payload = req.body as CreateTicketReferenceRequest; + const createdReference = await ticketService.createTicketReference(req.params.ticketId, payload); + + await connection.commit(); + + return res.status(201).json(createdReference); + } catch (error) { + defaultLog.error({ label: 'createTicketReference', message: 'error', error }); + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + }; +} diff --git a/api/src/paths/tickets/{ticketId}/reference/{ticketReferenceId}/index.test.ts b/api/src/paths/tickets/{ticketId}/reference/{ticketReferenceId}/index.test.ts new file mode 100644 index 000000000..8438866c5 --- /dev/null +++ b/api/src/paths/tickets/{ticketId}/reference/{ticketReferenceId}/index.test.ts @@ -0,0 +1,37 @@ +import chai, { expect } from 'chai'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import * as db from '../../../../../database/db'; +import { TicketService } from '../../../../../services/ticket-service'; +import { getMockDBConnection, getRequestHandlerMocks } from '../../../../../__mocks__/db'; +import { deleteTicketReference } from './index'; + +chai.use(sinonChai); + +describe('paths/tickets/{ticketId}/reference/{ticketReferenceId}', () => { + const ticketId = '11111111-1111-1111-1111-111111111111'; + const ticketReferenceId = '33333333-3333-3333-3333-333333333333'; + + afterEach(() => { + sinon.restore(); + }); + + it('DELETE removes a ticket reference', async () => { + const mockDBConnection = getMockDBConnection({ + commit: sinon.stub(), + rollback: sinon.stub(), + release: sinon.stub() + }); + sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + + const deleteReferenceStub = sinon.stub(TicketService.prototype, 'deleteTicketReference').resolves(); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + mockReq.params = { ticketId, ticketReferenceId }; + + await deleteTicketReference()(mockReq, mockRes, mockNext); + + expect(deleteReferenceStub).to.have.been.calledWith(ticketId, ticketReferenceId); + expect(mockRes.statusValue).to.equal(204); + }); +}); diff --git a/api/src/paths/tickets/{ticketId}/reference/{ticketReferenceId}/index.ts b/api/src/paths/tickets/{ticketId}/reference/{ticketReferenceId}/index.ts new file mode 100644 index 000000000..5ffcd851d --- /dev/null +++ b/api/src/paths/tickets/{ticketId}/reference/{ticketReferenceId}/index.ts @@ -0,0 +1,71 @@ +import { RequestHandler } from 'express'; +import { Operation } from 'express-openapi'; +import { SYSTEM_ROLE } from '../../../../../constants/roles'; +import { getDBConnection } from '../../../../../database/db'; +import { defaultErrorResponses } from '../../../../../openapi/schemas/http-responses'; +import { authorizeRequestHandler } from '../../../../../request-handlers/security/authorization'; +import { TicketService } from '../../../../../services/ticket-service'; +import { getLogger } from '../../../../../utils/logger'; + +const defaultLog = getLogger('paths/tickets/{ticketId}/reference/{ticketReferenceId}'); + +export const DELETE: Operation = [ + authorizeRequestHandler(() => ({ + and: [{ validSystemRoles: [SYSTEM_ROLE.SYSTEM_ADMIN], discriminator: 'SystemRole' }] + })), + deleteTicketReference() +]; + +DELETE.apiDoc = { + description: 'Delete a ticket reference by id', + tags: ['tickets'], + security: [{ Bearer: [] }], + parameters: [ + { + in: 'path', + name: 'ticketId', + required: true, + schema: { type: 'string', format: 'uuid' }, + description: 'Ticket UUID.' + }, + { + in: 'path', + name: 'ticketReferenceId', + required: true, + schema: { type: 'string', format: 'uuid' }, + description: 'Ticket reference UUID.' + } + ], + responses: { + 204: { description: 'Ticket reference deleted successfully' }, + ...defaultErrorResponses + } +}; + +/** + * Delete a ticket reference. + * + * @returns {RequestHandler} + */ +export function deleteTicketReference(): RequestHandler { + return async (req, res) => { + const connection = getDBConnection(req.keycloak_token); + + try { + await connection.open(); + + const ticketService = new TicketService(connection); + await ticketService.deleteTicketReference(req.params.ticketId, req.params.ticketReferenceId); + + await connection.commit(); + + return res.status(204).send(); + } catch (error) { + defaultLog.error({ label: 'deleteTicketReference', message: 'error', error }); + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + }; +} diff --git a/api/src/paths/tickets/{ticketId}/status/index.test.ts b/api/src/paths/tickets/{ticketId}/status/index.test.ts new file mode 100644 index 000000000..50c09a68d --- /dev/null +++ b/api/src/paths/tickets/{ticketId}/status/index.test.ts @@ -0,0 +1,48 @@ +import chai, { expect } from 'chai'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import * as db from '../../../../database/db'; +import { Ticket } from '../../../../models/ticket'; +import { TicketService } from '../../../../services/ticket-service'; +import { getMockDBConnection, getRequestHandlerMocks } from '../../../../__mocks__/db'; +import { updateTicketStatus } from './index'; + +chai.use(sinonChai); + +describe('paths/tickets/{ticketId}/status', () => { + afterEach(() => { + sinon.restore(); + }); + + it('PUT status delegates to updateTicket with status payload', async () => { + const mockDBConnection = getMockDBConnection({ + commit: sinon.stub(), + rollback: sinon.stub(), + release: sinon.stub() + }); + sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + + const updatedTicket: Ticket = { + ticket_id: '11111111-1111-1111-1111-111111111111', + ticket_slug: '04900001', + subject: 'A ticket', + description: 'desc', + team_id: '22222222-2222-2222-2222-222222222222', + create_date: '2026-02-25T00:00:00.000Z', + priority: 'medium', + status: 'closed' + }; + + const updateStub = sinon.stub(TicketService.prototype, 'updateTicket').resolves(updatedTicket); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + mockReq.params = { ticketId: updatedTicket.ticket_id }; + mockReq.body = { status: 'closed' }; + + await updateTicketStatus()(mockReq, mockRes, mockNext); + + expect(updateStub).to.have.been.calledWith(updatedTicket.ticket_id, { status: 'closed' }); + expect(mockRes.statusValue).to.equal(200); + expect(mockRes.jsonValue).to.eql(updatedTicket); + }); +}); diff --git a/api/src/paths/tickets/{ticketId}/status/index.ts b/api/src/paths/tickets/{ticketId}/status/index.ts new file mode 100644 index 000000000..15de48a75 --- /dev/null +++ b/api/src/paths/tickets/{ticketId}/status/index.ts @@ -0,0 +1,77 @@ +import { RequestHandler } from 'express'; +import { Operation } from 'express-openapi'; +import { SYSTEM_ROLE } from '../../../../constants/roles'; +import { getDBConnection } from '../../../../database/db'; +import { defaultErrorResponses } from '../../../../openapi/schemas/http-responses'; +import { TicketSchema, UpdateTicketStatusRequestSchema } from '../../../../openapi/schemas/ticket'; +import { authorizeRequestHandler } from '../../../../request-handlers/security/authorization'; +import { TicketService } from '../../../../services/ticket-service'; +import { getLogger } from '../../../../utils/logger'; + +const defaultLog = getLogger('paths/tickets/{ticketId}/status'); + +export const PUT: Operation = [ + authorizeRequestHandler(() => ({ + and: [{ validSystemRoles: [SYSTEM_ROLE.SYSTEM_ADMIN], discriminator: 'SystemRole' }] + })), + updateTicketStatus() +]; + +PUT.apiDoc = { + description: 'Change the status of a ticket', + tags: ['tickets'], + security: [{ Bearer: [] }], + parameters: [ + { + in: 'path', + name: 'ticketId', + required: true, + schema: { + type: 'string', + format: 'uuid' + }, + description: 'Ticket UUID.' + } + ], + requestBody: { + content: { + 'application/json': { + schema: UpdateTicketStatusRequestSchema + } + } + }, + responses: { + 200: { + description: 'Ticket status updated successfully', + content: { + 'application/json': { + schema: TicketSchema + } + } + }, + ...defaultErrorResponses + } +}; + +export function updateTicketStatus(): RequestHandler { + return async (req, res) => { + const connection = getDBConnection(req.keycloak_token); + + try { + await connection.open(); + + const ticketService = new TicketService(connection); + const ticket = await ticketService.updateTicket(req.params.ticketId, { status: req.body.status }); + + await connection.commit(); + + return res.status(200).json(ticket); + } catch (error) { + defaultLog.error({ label: 'updateTicketStatus', message: 'error', error }); + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + }; +} diff --git a/api/src/repositories/comment-repository.test.ts b/api/src/repositories/comment-repository.test.ts index b44f3ee37..93c6e7b40 100644 --- a/api/src/repositories/comment-repository.test.ts +++ b/api/src/repositories/comment-repository.test.ts @@ -93,4 +93,43 @@ describe('CommentRepository', () => { } }); }); + + describe('updateComment', () => { + it('should update and return the comment', async () => { + const mockQueryResponse = { + rowCount: 1, + rows: [mockComment] + } as unknown as QueryResult; + + const mockDBConnection = getMockDBConnection({ + knex: async () => mockQueryResponse + }); + + const repo = new CommentRepository(mockDBConnection); + + const result = await repo.updateComment(mockComment.comment_id, { comment: mockComment.comment }); + + expect(result).to.eql(mockComment); + }); + + it('should throw error when rowCount !== 1', async () => { + const mockQueryResponse = { + rowCount: 0, + rows: [] + } as unknown as QueryResult; + + const mockDBConnection = getMockDBConnection({ + knex: async () => mockQueryResponse + }); + + const repo = new CommentRepository(mockDBConnection); + + try { + await repo.updateComment(mockComment.comment_id, { comment: 'Updated comment' }); + throw new Error('Expected to throw'); + } catch (err) { + expect((err as Error).message).to.equal('Failed to update comment'); + } + }); + }); }); diff --git a/api/src/repositories/comment-repository.ts b/api/src/repositories/comment-repository.ts index 3accc47c9..281cc2b46 100644 --- a/api/src/repositories/comment-repository.ts +++ b/api/src/repositories/comment-repository.ts @@ -1,6 +1,6 @@ import { getKnex } from '../database/db'; import { ApiExecuteSQLError } from '../errors/api-error'; -import { Comment } from '../models/comment'; +import { Comment, UpdateComment } from '../models/comment'; import { BaseRepository } from './base-repository'; /** @@ -47,4 +47,28 @@ export class CommentRepository extends BaseRepository { } return response.rows[0]; } + + /** + * Update an existing comment body. + * + * @param {string} commentId + * @param {UpdateComment} payload + * @return {Promise} + * @memberof CommentRepository + */ + async updateComment(commentId: string, payload: UpdateComment): Promise { + const knex = getKnex(); + const query = knex('comment') + .update({ comment: payload.comment }) + .where('comment_id', commentId) + .returning(['comment', 'comment_id']); + + const response = await this.connection.knex(query, Comment); + + if (response.rowCount !== 1) { + throw new ApiExecuteSQLError('Failed to update comment', ['CommentRepository->updateComment', 'rowCount !== 1']); + } + + return response.rows[0]; + } } diff --git a/api/src/repositories/ticket-comment-repository.test.ts b/api/src/repositories/ticket-comment-repository.test.ts new file mode 100644 index 000000000..7d8cf22ba --- /dev/null +++ b/api/src/repositories/ticket-comment-repository.test.ts @@ -0,0 +1,165 @@ +import chai, { expect } from 'chai'; +import { describe } from 'mocha'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import { ApiExecuteSQLError } from '../errors/api-error'; +import { getMockDBConnection, mockQueryResult } from '../__mocks__/db'; +import { TicketCommentRepository } from './ticket-comment-repository'; + +chai.use(sinonChai); + +describe('TicketCommentRepository', () => { + afterEach(() => { + sinon.restore(); + }); + + const mockTicketId = '11111111-1111-1111-1111-111111111111'; + + describe('insertTicketComment', () => { + it('throws when insert fails', async () => { + const mockQueryResponse = Promise.resolve(mockQueryResult([])); + const mockDBConnection = getMockDBConnection({ knex: () => mockQueryResponse }); + const repo = new TicketCommentRepository(mockDBConnection); + + try { + await repo.insertTicketComment(mockTicketId, '44444444-4444-4444-4444-444444444444'); + expect.fail(); + } catch (error) { + expect(error).to.be.instanceOf(ApiExecuteSQLError); + if (error instanceof ApiExecuteSQLError) { + expect(error.message).to.equal('Failed to insert ticket comment'); + } + } + }); + + it('returns inserted ticket_comment_id', async () => { + const mockRow = { + ticket_comment_id: '33333333-3333-3333-3333-333333333333' + }; + const mockQueryResponse = Promise.resolve(mockQueryResult([mockRow])); + const mockDBConnection = getMockDBConnection({ knex: () => mockQueryResponse }); + const repo = new TicketCommentRepository(mockDBConnection); + + const result = await repo.insertTicketComment(mockTicketId, '44444444-4444-4444-4444-444444444444'); + expect(result).to.eql(mockRow); + }); + }); + + describe('getTicketCommentById', () => { + it('throws when not found', async () => { + const mockQueryResponse = Promise.resolve(mockQueryResult([])); + const mockDBConnection = getMockDBConnection({ sql: () => mockQueryResponse }); + const repo = new TicketCommentRepository(mockDBConnection); + + try { + await repo.getTicketCommentById(mockTicketId, '33333333-3333-3333-3333-333333333333'); + expect.fail(); + } catch (error) { + expect(error).to.be.instanceOf(ApiExecuteSQLError); + if (error instanceof ApiExecuteSQLError) { + expect(error.message).to.equal('Failed to get ticket comment'); + } + } + }); + + it('returns a single comment row', async () => { + const mockRow = { + ticket_comment_id: '33333333-3333-3333-3333-333333333333', + ticket_id: mockTicketId, + user_identifier: 'Sarah', + create_date: '2026-02-25T00:00:00.000Z', + comment: 'New comment' + }; + const mockQueryResponse = Promise.resolve(mockQueryResult([mockRow])); + const mockDBConnection = getMockDBConnection({ sql: () => mockQueryResponse }); + const repo = new TicketCommentRepository(mockDBConnection); + + const result = await repo.getTicketCommentById(mockTicketId, '33333333-3333-3333-3333-333333333333'); + expect(result).to.eql(mockRow); + }); + }); + + describe('updateTicketComment', () => { + it('throws when update fails', async () => { + const mockQueryResponse = Promise.resolve(mockQueryResult([])); + const mockDBConnection = getMockDBConnection({ sql: () => mockQueryResponse }); + const repo = new TicketCommentRepository(mockDBConnection); + + try { + await repo.updateTicketComment(mockTicketId, '33333333-3333-3333-3333-333333333333', 'Updated comment'); + expect.fail(); + } catch (error) { + expect(error).to.be.instanceOf(ApiExecuteSQLError); + if (error instanceof ApiExecuteSQLError) { + expect(error.message).to.equal('Failed to update ticket comment'); + } + } + }); + + it('returns updated comment_id', async () => { + const mockRow = { + comment_id: '44444444-4444-4444-4444-444444444444' + }; + const mockQueryResponse = Promise.resolve(mockQueryResult([mockRow])); + const mockDBConnection = getMockDBConnection({ sql: () => mockQueryResponse }); + const repo = new TicketCommentRepository(mockDBConnection); + + const result = await repo.updateTicketComment( + mockTicketId, + '33333333-3333-3333-3333-333333333333', + 'Updated comment' + ); + expect(result).to.eql(mockRow); + }); + }); + + describe('deleteTicketComment', () => { + it('throws when delete fails', async () => { + const mockQueryResponse = Promise.resolve(mockQueryResult([])); + const mockDBConnection = getMockDBConnection({ knex: () => mockQueryResponse }); + const repo = new TicketCommentRepository(mockDBConnection); + + try { + await repo.deleteTicketComment(mockTicketId, '33333333-3333-3333-3333-333333333333'); + expect.fail(); + } catch (error) { + expect(error).to.be.instanceOf(ApiExecuteSQLError); + if (error instanceof ApiExecuteSQLError) { + expect(error.message).to.equal('Failed to delete ticket comment'); + } + } + }); + + it('returns deleted ticket_comment_id', async () => { + const mockRow = { + ticket_comment_id: '33333333-3333-3333-3333-333333333333' + }; + const mockQueryResponse = Promise.resolve(mockQueryResult([mockRow])); + const mockDBConnection = getMockDBConnection({ knex: () => mockQueryResponse }); + const repo = new TicketCommentRepository(mockDBConnection); + + const result = await repo.deleteTicketComment(mockTicketId, '33333333-3333-3333-3333-333333333333'); + expect(result).to.eql(mockRow); + }); + }); + + describe('getTicketComments', () => { + it('returns comment rows ordered by query', async () => { + const rows = [ + { + ticket_comment_id: '33333333-3333-3333-3333-333333333333', + ticket_id: mockTicketId, + user_identifier: 'Sarah', + create_date: '2026-02-25T00:00:00.000Z', + comment: 'New comment' + } + ]; + const mockQueryResponse = Promise.resolve(mockQueryResult(rows)); + const mockDBConnection = getMockDBConnection({ sql: () => mockQueryResponse }); + const repo = new TicketCommentRepository(mockDBConnection); + + const result = await repo.getTicketComments(mockTicketId); + expect(result).to.eql(rows); + }); + }); +}); diff --git a/api/src/repositories/ticket-comment-repository.ts b/api/src/repositories/ticket-comment-repository.ts new file mode 100644 index 000000000..06a36066d --- /dev/null +++ b/api/src/repositories/ticket-comment-repository.ts @@ -0,0 +1,174 @@ +import { SQL } from 'sql-template-strings'; +import { z } from 'zod'; +import { getKnex } from '../database/db'; +import { ApiExecuteSQLError } from '../errors/api-error'; +import { TicketComment } from '../models/ticket-comment'; +import { BaseRepository } from './base-repository'; + +/** + * Repository for ticket_comment table operations. + */ +export class TicketCommentRepository extends BaseRepository { + /** + * Insert a ticket_comment link row for an existing comment. + * + * @param {string} ticketId - Ticket UUID. + * @param {string} commentId - Comment UUID. + * @return {Promise<{ ticket_comment_id: string }>} Inserted link row identifier. + * @throws {ApiExecuteSQLError} If the insert does not affect exactly one row. + * @memberof TicketCommentRepository + */ + async insertTicketComment(ticketId: string, commentId: string): Promise<{ ticket_comment_id: string }> { + const knex = getKnex(); + const query = knex('ticket_comment') + .insert({ ticket_id: ticketId, comment_id: commentId }) + .returning(['ticket_comment_id']); + + const response = await this.connection.knex(query, z.object({ ticket_comment_id: z.string() })); + + if (response.rowCount !== 1) { + throw new ApiExecuteSQLError('Failed to insert ticket comment', [ + 'TicketCommentRepository->insertTicketComment', + `rowCount was ${response.rowCount}, expected 1` + ]); + } + + return response.rows[0]; + } + + /** + * Soft delete a ticket comment link row scoped to a ticket. + * + * @param {string} ticketId - Ticket UUID. + * @param {string} ticketCommentId - Ticket comment UUID. + * @return {Promise<{ ticket_comment_id: string }>} Deleted ticket comment identifier. + * @throws {ApiExecuteSQLError} If the delete does not affect exactly one row. + * @memberof TicketCommentRepository + */ + async deleteTicketComment(ticketId: string, ticketCommentId: string): Promise<{ ticket_comment_id: string }> { + const knex = getKnex(); + const query = knex('ticket_comment') + .update({ record_end_date: knex.fn.now() }) + .where('ticket_id', ticketId) + .where('ticket_comment_id', ticketCommentId) + .whereNull('record_end_date') + .returning(['ticket_comment_id']); + + const response = await this.connection.knex(query, z.object({ ticket_comment_id: z.string() })); + + if (response.rowCount !== 1) { + throw new ApiExecuteSQLError('Failed to delete ticket comment', [ + 'TicketCommentRepository->deleteTicketComment', + `rowCount was ${response.rowCount}, expected 1` + ]); + } + + return response.rows[0]; + } + + /** + * Update a comment body scoped by ticket and ticket_comment link. + * + * @param {string} ticketId + * @param {string} ticketCommentId + * @param {string} comment + * @return {Promise<{ comment_id: string }>} + * @memberof TicketCommentRepository + */ + async updateTicketComment( + ticketId: string, + ticketCommentId: string, + comment: string + ): Promise<{ comment_id: string }> { + const sqlStatement = SQL` + UPDATE comment c + SET comment = ${comment} + FROM ticket_comment tc + WHERE tc.comment_id = c.comment_id + AND tc.ticket_id = ${ticketId} + AND tc.ticket_comment_id = ${ticketCommentId} + AND tc.record_end_date IS NULL + RETURNING c.comment_id; + `; + + const response = await this.connection.sql(sqlStatement, z.object({ comment_id: z.string() })); + + if (response.rowCount !== 1) { + throw new ApiExecuteSQLError('Failed to update ticket comment', [ + 'TicketCommentRepository->updateTicketComment', + `rowCount was ${response.rowCount}, expected 1` + ]); + } + + return response.rows[0]; + } + + /** + * Get a single comment row by ticket_comment_id. + * + * @param {string} ticketId - Ticket UUID. + * @param {string} ticketCommentId - Ticket comment UUID. + * @return {Promise} Ticket comment row. + * @throws {ApiExecuteSQLError} If exactly one row is not found. + * @memberof TicketCommentRepository + */ + async getTicketCommentById(ticketId: string, ticketCommentId: string): Promise { + const sqlStatement = SQL` + SELECT + tc.ticket_comment_id, + tc.ticket_id, + su.user_identifier, + tc.create_date, + c.comment + FROM ticket_comment tc + JOIN comment c + ON c.comment_id = tc.comment_id + JOIN "system_user" su + ON su.system_user_id = tc.create_user + WHERE tc.ticket_id = ${ticketId} + AND tc.ticket_comment_id = ${ticketCommentId} + AND tc.record_end_date IS NULL; + `; + + const response = await this.connection.sql(sqlStatement, TicketComment); + + if (response.rowCount !== 1) { + throw new ApiExecuteSQLError('Failed to get ticket comment', [ + 'TicketCommentRepository->getTicketCommentById', + `rowCount was ${response.rowCount}, expected 1` + ]); + } + + return response.rows[0]; + } + + /** + * Get comment history rows for a ticket ordered oldest first. + * + * @param {string} ticketId - Ticket UUID. + * @return {Promise} Ticket comment rows. + * @memberof TicketCommentRepository + */ + async getTicketComments(ticketId: string): Promise { + const sqlStatement = SQL` + SELECT + tc.ticket_comment_id, + tc.ticket_id, + su.user_identifier, + tc.create_date, + c.comment + FROM ticket_comment tc + JOIN comment c + ON c.comment_id = tc.comment_id + JOIN "system_user" su + ON su.system_user_id = tc.create_user + WHERE tc.ticket_id = ${ticketId} + AND tc.record_end_date IS NULL + ORDER BY tc.create_date ASC; + `; + + const response = await this.connection.sql(sqlStatement, TicketComment); + + return response.rows; + } +} diff --git a/api/src/repositories/ticket-reference-repository.test.ts b/api/src/repositories/ticket-reference-repository.test.ts new file mode 100644 index 000000000..c05f6e705 --- /dev/null +++ b/api/src/repositories/ticket-reference-repository.test.ts @@ -0,0 +1,148 @@ +import chai, { expect } from 'chai'; +import { describe } from 'mocha'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import { ApiExecuteSQLError } from '../errors/api-error'; +import { TicketReference } from '../models/ticket-reference'; +import { getMockDBConnection, mockQueryResult } from '../__mocks__/db'; +import { TicketReferenceRepository } from './ticket-reference-repository'; + +chai.use(sinonChai); + +describe('TicketReferenceRepository', () => { + afterEach(() => { + sinon.restore(); + }); + + const mockSourceTicketId = '11111111-1111-1111-1111-111111111111'; + const mockTargetTicketId = '22222222-2222-2222-2222-222222222222'; + const mockTicketReferenceId = '33333333-3333-3333-3333-333333333333'; + + describe('insertTicketReference', () => { + it('throws when insert fails', async () => { + const mockQueryResponse = Promise.resolve(mockQueryResult([])); + const mockDBConnection = getMockDBConnection({ knex: () => mockQueryResponse }); + const repo = new TicketReferenceRepository(mockDBConnection); + + try { + await repo.insertTicketReference({ + source_ticket_id: mockSourceTicketId, + target_ticket_id: mockTargetTicketId, + relationship: 'relates_to' + }); + expect.fail(); + } catch (error) { + expect(error).to.be.instanceOf(ApiExecuteSQLError); + if (error instanceof ApiExecuteSQLError) { + expect(error.message).to.equal('Failed to insert ticket reference'); + } + } + }); + + it('returns inserted ticket_reference_id', async () => { + const mockRow = { ticket_reference_id: mockTicketReferenceId }; + const mockQueryResponse = Promise.resolve(mockQueryResult([mockRow])); + const mockDBConnection = getMockDBConnection({ knex: () => mockQueryResponse }); + const repo = new TicketReferenceRepository(mockDBConnection); + + const result = await repo.insertTicketReference({ + source_ticket_id: mockSourceTicketId, + target_ticket_id: mockTargetTicketId, + relationship: 'relates_to' + }); + expect(result).to.eql(mockRow); + }); + }); + + describe('getTicketReferenceById', () => { + it('throws when not found', async () => { + const mockQueryResponse = Promise.resolve(mockQueryResult([])); + const mockDBConnection = getMockDBConnection({ sql: () => mockQueryResponse }); + const repo = new TicketReferenceRepository(mockDBConnection); + + try { + await repo.getTicketReferenceById(mockTicketReferenceId); + expect.fail(); + } catch (error) { + expect(error).to.be.instanceOf(ApiExecuteSQLError); + if (error instanceof ApiExecuteSQLError) { + expect(error.message).to.equal('Failed to get ticket reference'); + } + } + }); + + it('returns a single ticket reference row', async () => { + const mockRow: TicketReference = { + ticket_reference_id: mockTicketReferenceId, + source_ticket_id: mockSourceTicketId, + source_ticket_slug: '04900001', + source_ticket_subject: 'Source ticket', + target_ticket_id: mockTargetTicketId, + target_ticket_slug: '04900002', + target_ticket_subject: 'Target ticket', + relationship: 'relates_to', + user_identifier: 'Sarah', + create_date: '2026-02-25T00:00:00.000Z' + }; + const mockQueryResponse = Promise.resolve(mockQueryResult([mockRow])); + const mockDBConnection = getMockDBConnection({ sql: () => mockQueryResponse }); + const repo = new TicketReferenceRepository(mockDBConnection); + + const result = await repo.getTicketReferenceById(mockTicketReferenceId); + expect(result).to.eql(mockRow); + }); + }); + + describe('deleteTicketReference', () => { + it('throws when delete fails', async () => { + const mockQueryResponse = Promise.resolve(mockQueryResult([])); + const mockDBConnection = getMockDBConnection({ knex: () => mockQueryResponse }); + const repo = new TicketReferenceRepository(mockDBConnection); + + try { + await repo.deleteTicketReference(mockSourceTicketId, mockTicketReferenceId); + expect.fail(); + } catch (error) { + expect(error).to.be.instanceOf(ApiExecuteSQLError); + if (error instanceof ApiExecuteSQLError) { + expect(error.message).to.equal('Failed to delete ticket reference'); + } + } + }); + + it('returns deleted ticket_reference_id', async () => { + const mockRow = { ticket_reference_id: mockTicketReferenceId }; + const mockQueryResponse = Promise.resolve(mockQueryResult([mockRow])); + const mockDBConnection = getMockDBConnection({ knex: () => mockQueryResponse }); + const repo = new TicketReferenceRepository(mockDBConnection); + + const result = await repo.deleteTicketReference(mockSourceTicketId, mockTicketReferenceId); + expect(result).to.eql(mockRow); + }); + }); + + describe('getTicketReferencesForTicket', () => { + it('returns reference rows ordered by query', async () => { + const rows: TicketReference[] = [ + { + ticket_reference_id: mockTicketReferenceId, + source_ticket_id: mockSourceTicketId, + source_ticket_slug: '04900001', + source_ticket_subject: 'Source ticket', + target_ticket_id: mockTargetTicketId, + target_ticket_slug: '04900002', + target_ticket_subject: 'Target ticket', + relationship: 'relates_to', + user_identifier: 'Sarah', + create_date: '2026-02-25T00:00:00.000Z' + } + ]; + const mockQueryResponse = Promise.resolve(mockQueryResult(rows)); + const mockDBConnection = getMockDBConnection({ sql: () => mockQueryResponse }); + const repo = new TicketReferenceRepository(mockDBConnection); + + const result = await repo.getTicketReferencesForTicket(mockSourceTicketId); + expect(result).to.eql(rows); + }); + }); +}); diff --git a/api/src/repositories/ticket-reference-repository.ts b/api/src/repositories/ticket-reference-repository.ts new file mode 100644 index 000000000..093991bd5 --- /dev/null +++ b/api/src/repositories/ticket-reference-repository.ts @@ -0,0 +1,152 @@ +import { SQL } from 'sql-template-strings'; +import { z } from 'zod'; +import { getKnex } from '../database/db'; +import { ApiExecuteSQLError } from '../errors/api-error'; +import { CreateTicketReference, TicketReference } from '../models/ticket-reference'; +import { BaseRepository } from './base-repository'; + +/** + * Repository for ticket_reference table operations. + */ +export class TicketReferenceRepository extends BaseRepository { + /** + * Insert a ticket_reference row. + * + * @param {CreateTicketReference} payload - Ticket reference payload. + * @return {Promise<{ ticket_reference_id: string }>} Inserted ticket reference identifier. + * @throws {ApiExecuteSQLError} If the insert does not affect exactly one row. + * @memberof TicketReferenceRepository + */ + async insertTicketReference(payload: CreateTicketReference): Promise<{ ticket_reference_id: string }> { + const knex = getKnex(); + const query = knex('ticket_reference') + .insert({ + source_ticket_id: payload.source_ticket_id, + target_ticket_id: payload.target_ticket_id, + relationship: payload.relationship + }) + .returning(['ticket_reference_id']); + + const response = await this.connection.knex(query, z.object({ ticket_reference_id: z.string() })); + + if (response.rowCount !== 1) { + throw new ApiExecuteSQLError('Failed to insert ticket reference', [ + 'TicketReferenceRepository->insertTicketReference', + `rowCount was ${response.rowCount}, expected 1` + ]); + } + + return response.rows[0]; + } + + /** + * Soft delete a ticket reference row scoped to a ticket. + * + * @param {string} ticketId - Ticket UUID. + * @param {string} ticketReferenceId - Ticket reference UUID. + * @return {Promise<{ ticket_reference_id: string }>} Deleted ticket reference identifier. + * @throws {ApiExecuteSQLError} If the delete does not affect exactly one row. + * @memberof TicketReferenceRepository + */ + async deleteTicketReference(ticketId: string, ticketReferenceId: string): Promise<{ ticket_reference_id: string }> { + const knex = getKnex(); + const query = knex('ticket_reference') + .update({ record_end_date: knex.fn.now() }) + .where('ticket_reference_id', ticketReferenceId) + .andWhere((builder) => builder.where('source_ticket_id', ticketId).orWhere('target_ticket_id', ticketId)) + .whereNull('record_end_date') + .returning(['ticket_reference_id']); + + const response = await this.connection.knex(query, z.object({ ticket_reference_id: z.string() })); + + if (response.rowCount !== 1) { + throw new ApiExecuteSQLError('Failed to delete ticket reference', [ + 'TicketReferenceRepository->deleteTicketReference', + `rowCount was ${response.rowCount}, expected 1` + ]); + } + + return response.rows[0]; + } + + /** + * Get a single ticket reference row by identifier. + * + * @param {string} ticketReferenceId - Ticket reference UUID. + * @return {Promise} Ticket reference row. + * @throws {ApiExecuteSQLError} If exactly one row is not found. + * @memberof TicketReferenceRepository + */ + async getTicketReferenceById(ticketReferenceId: string): Promise { + const sqlStatement = SQL` + SELECT + tr.ticket_reference_id, + tr.source_ticket_id, + st.ticket_slug AS source_ticket_slug, + st.subject AS source_ticket_subject, + tr.target_ticket_id, + tt.ticket_slug AS target_ticket_slug, + tt.subject AS target_ticket_subject, + tr.relationship, + su.user_identifier, + tr.create_date + FROM ticket_reference tr + JOIN ticket st + ON st.ticket_id = tr.source_ticket_id + JOIN ticket tt + ON tt.ticket_id = tr.target_ticket_id + JOIN "system_user" su + ON su.system_user_id = tr.create_user + WHERE tr.ticket_reference_id = ${ticketReferenceId} + AND tr.record_end_date IS NULL; + `; + + const response = await this.connection.sql(sqlStatement, TicketReference); + + if (response.rowCount !== 1) { + throw new ApiExecuteSQLError('Failed to get ticket reference', [ + 'TicketReferenceRepository->getTicketReferenceById', + `rowCount was ${response.rowCount}, expected 1` + ]); + } + + return response.rows[0]; + } + + /** + * Get active references for a ticket where it is either source or target. + * + * @param {string} ticketId - Ticket UUID. + * @return {Promise} Ticket reference rows. + * @memberof TicketReferenceRepository + */ + async getTicketReferencesForTicket(ticketId: string): Promise { + const sqlStatement = SQL` + SELECT + tr.ticket_reference_id, + tr.source_ticket_id, + st.ticket_slug AS source_ticket_slug, + st.subject AS source_ticket_subject, + tr.target_ticket_id, + tt.ticket_slug AS target_ticket_slug, + tt.subject AS target_ticket_subject, + tr.relationship, + su.user_identifier, + tr.create_date + FROM ticket_reference tr + JOIN ticket st + ON st.ticket_id = tr.source_ticket_id + JOIN ticket tt + ON tt.ticket_id = tr.target_ticket_id + JOIN "system_user" su + ON su.system_user_id = tr.create_user + WHERE (tr.source_ticket_id = ${ticketId} OR tr.target_ticket_id = ${ticketId}) + AND tr.record_end_date IS NULL + ORDER BY tr.create_date ASC; + `; + + const response = await this.connection.sql(sqlStatement, TicketReference); + + return response.rows; + } +} diff --git a/api/src/repositories/ticket-repository.test.ts b/api/src/repositories/ticket-repository.test.ts new file mode 100644 index 000000000..b5295136f --- /dev/null +++ b/api/src/repositories/ticket-repository.test.ts @@ -0,0 +1,172 @@ +import chai, { expect } from 'chai'; +import { describe } from 'mocha'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import { ApiExecuteSQLError } from '../errors/api-error'; +import { Ticket, TicketSlug } from '../models/ticket'; +import { getMockDBConnection, mockQueryResult } from '../__mocks__/db'; +import { TicketRepository } from './ticket-repository'; + +chai.use(sinonChai); + +describe('TicketRepository', () => { + afterEach(() => { + sinon.restore(); + }); + + const mockTicket: Ticket = { + ticket_id: '11111111-1111-1111-1111-111111111111', + ticket_slug: '04900001', + subject: 'A ticket', + description: 'desc', + team_id: '22222222-2222-2222-2222-222222222222', + create_date: '2026-02-25T00:00:00.000Z', + priority: 'medium', + status: 'open' + }; + + describe('insertTicket', () => { + it('throws an error if insert fails', async () => { + const mockQueryResponse = Promise.resolve(mockQueryResult([])); + const mockDBConnection = getMockDBConnection({ knex: () => mockQueryResponse }); + const repo = new TicketRepository(mockDBConnection); + + try { + await repo.insertTicket({ + subject: 'A', + description: null, + priority: 'medium', + team_id: mockTicket.team_id, + ticket_slug: mockTicket.ticket_slug + }); + expect.fail(); + } catch (error) { + expect(error).to.be.instanceOf(ApiExecuteSQLError); + if (error instanceof ApiExecuteSQLError) { + expect(error.message).to.equal('Failed to insert ticket record'); + } + } + }); + + it('returns created ticket', async () => { + const mockQueryResponse = Promise.resolve(mockQueryResult([mockTicket])); + const mockDBConnection = getMockDBConnection({ knex: () => mockQueryResponse }); + const repo = new TicketRepository(mockDBConnection); + + const result = await repo.insertTicket({ + subject: 'A', + description: null, + priority: 'medium', + team_id: mockTicket.team_id, + ticket_slug: mockTicket.ticket_slug + }); + expect(result).to.eql(mockTicket); + }); + }); + + describe('getNextTicketSlug', () => { + it('throws when slug generation fails', async () => { + const mockQueryResponse = Promise.resolve(mockQueryResult([])); + const mockDBConnection = getMockDBConnection({ sql: () => mockQueryResponse }); + const repo = new TicketRepository(mockDBConnection); + + try { + await repo.getNextTicketSlug(); + expect.fail(); + } catch (error) { + expect(error).to.be.instanceOf(ApiExecuteSQLError); + if (error instanceof ApiExecuteSQLError) { + expect(error.message).to.equal('Failed to generate ticket slug'); + } + } + }); + + it('returns the next slug value', async () => { + const sqlStub = sinon.stub().resolves(mockQueryResult([{ ticket_slug: '04900042' }])); + const mockDBConnection = getMockDBConnection({ sql: sqlStub }); + const repo = new TicketRepository(mockDBConnection); + + const result = await repo.getNextTicketSlug(); + + expect(sqlStub).to.have.been.calledWithMatch(sinon.match.any, TicketSlug); + expect(result).to.equal('04900042'); + }); + }); + + describe('getTicketById', () => { + it('throws when not found', async () => { + const mockQueryResponse = Promise.resolve(mockQueryResult([])); + const mockDBConnection = getMockDBConnection({ knex: () => mockQueryResponse }); + const repo = new TicketRepository(mockDBConnection); + + try { + await repo.getTicketById(mockTicket.ticket_id); + expect.fail(); + } catch (error) { + expect(error).to.be.instanceOf(ApiExecuteSQLError); + if (error instanceof ApiExecuteSQLError) { + expect(error.message).to.equal('Failed to get ticket record'); + } + } + }); + + it('returns ticket when found', async () => { + const mockQueryResponse = Promise.resolve(mockQueryResult([mockTicket])); + const mockDBConnection = getMockDBConnection({ knex: () => mockQueryResponse }); + const repo = new TicketRepository(mockDBConnection); + + const result = await repo.getTicketById(mockTicket.ticket_id); + expect(result).to.eql(mockTicket); + }); + }); + + describe('getTickets', () => { + it('returns matching tickets', async () => { + const mockQueryResponse = Promise.resolve(mockQueryResult([mockTicket])); + const mockDBConnection = getMockDBConnection({ knex: () => mockQueryResponse }); + const repo = new TicketRepository(mockDBConnection); + + const result = await repo.getTickets({ team_id: mockTicket.team_id, status: 'open' }, { page: 1, limit: 10 }); + expect(result).to.eql([mockTicket]); + }); + }); + + describe('getTicketsCount', () => { + it('returns count', async () => { + const mockQueryResponse = Promise.resolve(mockQueryResult([{ count: 7 }])); + const mockDBConnection = getMockDBConnection({ knex: () => mockQueryResponse }); + const repo = new TicketRepository(mockDBConnection); + + const result = await repo.getTicketsCount({ team_id: mockTicket.team_id, status: 'open' }); + expect(result).to.equal(7); + }); + }); + + describe('updateTicket', () => { + it('throws when update fails', async () => { + const mockQueryResponse = Promise.resolve(mockQueryResult([])); + const mockDBConnection = getMockDBConnection({ knex: () => mockQueryResponse }); + const repo = new TicketRepository(mockDBConnection); + + try { + await repo.updateTicket(mockTicket.ticket_id, { subject: 'new subject' }); + expect.fail(); + } catch (error) { + expect(error).to.be.instanceOf(ApiExecuteSQLError); + if (error instanceof ApiExecuteSQLError) { + expect(error.message).to.equal('Failed to update ticket record'); + } + } + }); + + it('returns updated ticket', async () => { + const updated: Ticket = { ...mockTicket, subject: 'new subject', status: 'closed' }; + const mockQueryResponse = Promise.resolve(mockQueryResult([updated])); + const mockDBConnection = getMockDBConnection({ knex: () => mockQueryResponse }); + const repo = new TicketRepository(mockDBConnection); + + const result = await repo.updateTicket(mockTicket.ticket_id, { subject: 'new subject', status: 'closed' }); + expect(result).to.eql(updated); + }); + }); +}); diff --git a/api/src/repositories/ticket-repository.ts b/api/src/repositories/ticket-repository.ts new file mode 100644 index 000000000..425309b8b --- /dev/null +++ b/api/src/repositories/ticket-repository.ts @@ -0,0 +1,243 @@ +import { Knex } from 'knex'; +import { SQL } from 'sql-template-strings'; +import { getKnex } from '../database/db'; +import { ApiExecuteSQLError } from '../errors/api-error'; +import { CreateTicketPayload, Ticket, TicketFilters, TicketSlug, UpdateTicketRequest } from '../models/ticket'; +import { ApiPaginationOptions } from '../zod-schema/pagination'; +import { BaseRepository } from './base-repository'; + +const TICKET_COLUMNS = [ + 'ticket_id', + 'ticket_slug', + 'subject', + 'description', + 'team_id', + 'create_date', + 'priority', + 'status' +] as const; + +export class TicketRepository extends BaseRepository { + /** + * Apply ticket filters to a base ticket query. + * + * @param {Knex.QueryBuilder} query + * @param {TicketFilters} [filters] + * @return {Knex.QueryBuilder} + * @memberof TicketRepository + */ + applyFilters(query: Knex.QueryBuilder, filters?: TicketFilters): Knex.QueryBuilder { + if (filters?.team_id) { + query = query.andWhere('team_id', filters.team_id); + } + + if (filters?.status) { + query = query.andWhere('status', filters.status); + } + + return query; + } + + /** + * Generate the next unique ticket slug for the current UTC day in DDDNNNNN format. + * + * Uses a transaction-scoped advisory lock plus existing ticket rows to guarantee uniqueness without retries. + * + * @return {Promise} Next ticket slug. + * @throws {ApiExecuteSQLError} If slug generation fails. + * @memberof TicketRepository + */ + async getNextTicketSlug(): Promise { + const sqlStatement = SQL` + WITH advisory_lock AS ( + SELECT pg_advisory_xact_lock(hashtext('ticket_slug_generation')) + ), + day_context AS ( + SELECT TO_CHAR((now() AT TIME ZONE 'UTC')::date, 'DDD') AS day_of_year + ), + latest AS ( + SELECT + COALESCE(MAX(RIGHT(ticket_slug, 5)::integer), -1) AS last_value + FROM ticket, day_context, advisory_lock + WHERE ticket_slug LIKE day_context.day_of_year || '%' + ), + next_value AS ( + SELECT + day_context.day_of_year, + latest.last_value + 1 AS next_sequence + FROM day_context, latest + ) + SELECT + day_of_year || LPAD(next_sequence::text, 5, '0') AS ticket_slug + FROM next_value; + `; + + const response = await this.connection.sql(sqlStatement, TicketSlug); + + if (response.rowCount !== 1) { + throw new ApiExecuteSQLError('Failed to generate ticket slug', [ + 'TicketRepository->getNextTicketSlug', + `rowCount was ${response.rowCount}, expected 1` + ]); + } + + return response.rows[0].ticket_slug; + } + + /** + * Insert a new ticket record. + * + * @param {CreateTicketPayload} ticket - Ticket payload to persist with resolved team ID and generated slug. + * @return {Promise} The created ticket record. + * @throws {ApiExecuteSQLError} If the insert does not affect exactly one row. + * @memberof TicketRepository + */ + async insertTicket(ticket: CreateTicketPayload): Promise { + const knex = getKnex(); + const query = knex + .table('ticket') + .insert({ + subject: ticket.subject, + description: ticket.description ?? null, + team_id: ticket.team_id, + ticket_slug: ticket.ticket_slug, + priority: ticket.priority + }) + .returning(TICKET_COLUMNS); + + const response = await this.connection.knex(query, Ticket); + + if (response.rowCount !== 1) { + throw new ApiExecuteSQLError('Failed to insert ticket record', [ + 'TicketRepository->insertTicket', + `rowCount was ${response.rowCount}, expected 1` + ]); + } + + return response.rows[0]; + } + + /** + * Get a single active ticket by UUID. + * + * @param {string} ticketId - Ticket UUID. + * @return {Promise} Matching ticket record. + * @throws {ApiExecuteSQLError} If exactly one active ticket is not found. + * @memberof TicketRepository + */ + async getTicketById(ticketId: string): Promise { + const knex = getKnex(); + const query = knex.table('ticket').select(TICKET_COLUMNS).where('ticket_id', ticketId).whereNull('record_end_date'); + + const response = await this.connection.knex(query, Ticket); + + if (response.rowCount !== 1) { + throw new ApiExecuteSQLError('Failed to get ticket record', [ + 'TicketRepository->getTicketById', + `rowCount was ${response.rowCount}, expected 1` + ]); + } + + return response.rows[0]; + } + + /** + * List active tickets with optional filters. + * + * @param {TicketFilters} [filters] - Optional list filters. + * @param {ApiPaginationOptions} [pagination] - Optional pagination options. + * @return {Promise} Matching tickets. + * @memberof TicketRepository + */ + async getTickets(filters?: TicketFilters, pagination?: ApiPaginationOptions): Promise { + const knex = getKnex(); + let query = knex.table('ticket').select(TICKET_COLUMNS).whereNull('record_end_date'); + query = this.applyFilters(query, filters); + query = this.applyPagination(query, pagination); + + const response = await this.connection.knex(query, Ticket); + + return response.rows; + } + + /** + * Count active tickets with optional filters. + * + * @param {TicketFilters} [filters] - Optional list filters. + * @return {Promise} Total number of matching tickets. + * @memberof TicketRepository + */ + async getTicketsCount(filters?: TicketFilters): Promise { + const knex = getKnex(); + let query = knex.table('ticket').whereNull('record_end_date').select(knex.raw('count(*)::integer as count')); + query = this.applyFilters(query, filters); + + const response = await this.connection.knex(query); + + return response.rows[0]?.count ?? 0; + } + + /** + * Update editable fields for a ticket, including status when provided. + * + * @param {string} ticketId - Ticket UUID. + * @param {UpdateTicketRequest} ticket - Partial update payload. + * @return {Promise} Updated ticket record. + * @throws {ApiExecuteSQLError} If the update does not affect exactly one row. + * @memberof TicketRepository + */ + async updateTicket(ticketId: string, ticket: UpdateTicketRequest): Promise { + const knex = getKnex(); + const query = knex + .table('ticket') + .update({ + subject: ticket.subject, + description: ticket.description, + priority: ticket.priority, + status: ticket.status + }) + .where('ticket_id', ticketId) + .whereNull('record_end_date') + .returning(TICKET_COLUMNS); + + const response = await this.connection.knex(query, Ticket); + + if (response.rowCount !== 1) { + throw new ApiExecuteSQLError('Failed to update ticket record', [ + 'TicketRepository->updateTicket', + `rowCount was ${response.rowCount}, expected 1` + ]); + } + + return response.rows[0]; + } + + /** + * Soft delete an active ticket by UUID. + * + * @param {string} ticketId - Ticket UUID. + * @return {Promise} Deleted ticket record. + * @throws {ApiExecuteSQLError} If the delete does not affect exactly one row. + * @memberof TicketRepository + */ + async deleteTicket(ticketId: string): Promise { + const knex = getKnex(); + const query = knex + .table('ticket') + .update({ record_end_date: knex.fn.now() }) + .where('ticket_id', ticketId) + .whereNull('record_end_date') + .returning(TICKET_COLUMNS); + + const response = await this.connection.knex(query, Ticket); + + if (response.rowCount !== 1) { + throw new ApiExecuteSQLError('Failed to delete ticket record', [ + 'TicketRepository->deleteTicket', + `rowCount was ${response.rowCount}, expected 1` + ]); + } + + return response.rows[0]; + } +} diff --git a/api/src/repositories/ticket-status-repository.test.ts b/api/src/repositories/ticket-status-repository.test.ts new file mode 100644 index 000000000..3aceb8711 --- /dev/null +++ b/api/src/repositories/ticket-status-repository.test.ts @@ -0,0 +1,72 @@ +import chai, { expect } from 'chai'; +import { describe } from 'mocha'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import { ApiExecuteSQLError } from '../errors/api-error'; +import { TicketStatus } from '../models/ticket-status'; +import { getMockDBConnection, mockQueryResult } from '../__mocks__/db'; +import { TicketStatusRepository } from './ticket-status-repository'; + +chai.use(sinonChai); + +describe('TicketStatusRepository', () => { + afterEach(() => { + sinon.restore(); + }); + + const mockTicketId = '11111111-1111-1111-1111-111111111111'; + + describe('insertTicketStatus', () => { + it('throws when insert fails', async () => { + const mockQueryResponse = Promise.resolve(mockQueryResult([])); + const mockDBConnection = getMockDBConnection({ sql: () => mockQueryResponse }); + const repo = new TicketStatusRepository(mockDBConnection); + + try { + await repo.insertTicketStatus(mockTicketId, 'open'); + expect.fail(); + } catch (error) { + expect(error).to.be.instanceOf(ApiExecuteSQLError); + if (error instanceof ApiExecuteSQLError) { + expect(error.message).to.equal('Failed to insert ticket status'); + } + } + }); + + it('returns inserted status history row', async () => { + const mockRow: TicketStatus = { + ticket_status_id: '33333333-3333-3333-3333-333333333333', + ticket_id: mockTicketId, + user_identifier: 'Sarah', + create_date: '2026-02-25T00:00:00.000Z', + status: 'open' + }; + const mockQueryResponse = Promise.resolve(mockQueryResult([mockRow])); + const mockDBConnection = getMockDBConnection({ sql: () => mockQueryResponse }); + const repo = new TicketStatusRepository(mockDBConnection); + + const result = await repo.insertTicketStatus(mockTicketId, 'open'); + expect(result).to.eql(mockRow); + }); + }); + + describe('getTicketStatus', () => { + it('returns rows ordered by query', async () => { + const rows: TicketStatus[] = [ + { + ticket_status_id: '33333333-3333-3333-3333-333333333333', + ticket_id: mockTicketId, + user_identifier: 'Sarah', + create_date: '2026-02-25T00:00:00.000Z', + status: 'open' + } + ]; + const mockQueryResponse = Promise.resolve(mockQueryResult(rows)); + const mockDBConnection = getMockDBConnection({ sql: () => mockQueryResponse }); + const repo = new TicketStatusRepository(mockDBConnection); + + const result = await repo.getTicketStatus(mockTicketId); + expect(result).to.eql(rows); + }); + }); +}); diff --git a/api/src/repositories/ticket-status-repository.ts b/api/src/repositories/ticket-status-repository.ts new file mode 100644 index 000000000..4ab387bd1 --- /dev/null +++ b/api/src/repositories/ticket-status-repository.ts @@ -0,0 +1,79 @@ +import { SQL } from 'sql-template-strings'; +import { ApiExecuteSQLError } from '../errors/api-error'; +import { TicketStatus as TicketStatusEnum } from '../models/ticket'; +import { TicketStatus } from '../models/ticket-status'; +import { BaseRepository } from './base-repository'; + +/** + * Repository for ticket_status table operations. + */ +export class TicketStatusRepository extends BaseRepository { + /** + * Insert an immutable status entry for a ticket. + * + * @param {string} ticketId - Ticket UUID. + * @param {TicketStatusEnum} status - Status value to append. + * @return {Promise} Created status row. + * @throws {ApiExecuteSQLError} If the insert does not affect exactly one row. + * @memberof TicketStatusRepository + */ + async insertTicketStatus(ticketId: string, status: TicketStatusEnum): Promise { + const sqlStatement = SQL` + INSERT INTO ticket_status ( + ticket_id, + status + ) VALUES ( + ${ticketId}, + ${status} + ) + RETURNING + ticket_status_id, + ticket_id, + ( + SELECT su.user_identifier + FROM "system_user" su + WHERE su.system_user_id = create_user + ), + create_date, + status; + `; + + const response = await this.connection.sql(sqlStatement, TicketStatus); + + if (response.rowCount !== 1) { + throw new ApiExecuteSQLError('Failed to insert ticket status', [ + 'TicketStatusRepository->insertTicketStatus', + `rowCount was ${response.rowCount}, expected 1` + ]); + } + + return response.rows[0]; + } + + /** + * Get status history for a ticket + * + * @param {string} ticketId - Ticket UUID. + * @return {Promise} Status rows. + * @memberof TicketStatusRepository + */ + async getTicketStatus(ticketId: string): Promise { + const sqlStatement = SQL` + SELECT + tsh.ticket_status_id, + tsh.ticket_id, + su.user_identifier, + tsh.create_date, + tsh.status + FROM ticket_status tsh + JOIN "system_user" su + ON su.system_user_id = tsh.create_user + WHERE tsh.ticket_id = ${ticketId} + ORDER BY tsh.create_date ASC; + `; + + const response = await this.connection.sql(sqlStatement, TicketStatus); + + return response.rows; + } +} diff --git a/api/src/services/ticket-comment-service.test.ts b/api/src/services/ticket-comment-service.test.ts new file mode 100644 index 000000000..e2ffad5be --- /dev/null +++ b/api/src/services/ticket-comment-service.test.ts @@ -0,0 +1,143 @@ +import chai, { expect } from 'chai'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import { Comment } from '../models/comment'; +import { TicketComment } from '../models/ticket-comment'; +import { CommentRepository } from '../repositories/comment-repository'; +import { TicketCommentRepository } from '../repositories/ticket-comment-repository'; +import { getMockDBConnection } from '../__mocks__/db'; +import { TicketCommentService } from './ticket-comment-service'; + +chai.use(sinonChai); + +describe('TicketCommentService', () => { + afterEach(() => { + sinon.restore(); + }); + + const mockTicketId = '11111111-1111-1111-1111-111111111111'; + const mockTicketCommentId = '33333333-3333-3333-3333-333333333333'; + + const mockComment: Comment = { + comment_id: '44444444-4444-4444-4444-444444444444', + comment: 'New comment' + }; + + const mockTicketComment: TicketComment = { + ticket_comment_id: mockTicketCommentId, + ticket_id: mockTicketId, + user_identifier: 'Sarah', + create_date: '2026-02-25T00:00:00.000Z', + comment: 'New comment' + }; + + describe('createTicketComment', () => { + it('creates comment, links it to ticket, and returns comment row', async () => { + const mockDBConnection = getMockDBConnection(); + const service = new TicketCommentService(mockDBConnection); + + const createCommentStub = sinon.stub(CommentRepository.prototype, 'createComment').resolves(mockComment); + const insertTicketCommentStub = sinon + .stub(TicketCommentRepository.prototype, 'insertTicketComment') + .resolves({ ticket_comment_id: mockTicketCommentId }); + const getTicketCommentByIdStub = sinon + .stub(TicketCommentRepository.prototype, 'getTicketCommentById') + .resolves(mockTicketComment); + + const result = await service.createTicketComment({ ticketId: mockTicketId, comment: 'New comment' }); + + expect(createCommentStub).to.have.been.calledOnceWith('New comment'); + expect(insertTicketCommentStub).to.have.been.calledOnceWith(mockTicketId, mockComment.comment_id); + expect(getTicketCommentByIdStub).to.have.been.calledOnceWith(mockTicketId, mockTicketCommentId); + expect(result).to.eql(mockTicketComment); + }); + + it('propagates repository errors', async () => { + const mockDBConnection = getMockDBConnection(); + const service = new TicketCommentService(mockDBConnection); + + sinon.stub(CommentRepository.prototype, 'createComment').rejects(new Error('DB error')); + + try { + await service.createTicketComment({ ticketId: mockTicketId, comment: 'New comment' }); + expect.fail('Expected error to be thrown'); + } catch (error) { + expect((error as Error).message).to.equal('DB error'); + } + }); + }); + + describe('deleteTicketComment', () => { + it('delegates delete to repository', async () => { + const mockDBConnection = getMockDBConnection(); + const service = new TicketCommentService(mockDBConnection); + + const deleteStub = sinon + .stub(TicketCommentRepository.prototype, 'deleteTicketComment') + .resolves({ ticket_comment_id: mockTicketCommentId }); + + await service.deleteTicketComment(mockTicketId, mockTicketCommentId); + + expect(deleteStub).to.have.been.calledOnceWith(mockTicketId, mockTicketCommentId); + }); + }); + + describe('updateTicketComment', () => { + it('updates comment body and returns updated ticket comment row', async () => { + const mockDBConnection = getMockDBConnection(); + const service = new TicketCommentService(mockDBConnection); + const updateTicketCommentStub = sinon + .stub(TicketCommentRepository.prototype, 'updateTicketComment') + .resolves({ comment_id: '44444444-4444-4444-4444-444444444444' }); + const getTicketCommentByIdStub = sinon.stub(TicketCommentRepository.prototype, 'getTicketCommentById').resolves({ + ...mockTicketComment, + comment: 'Updated comment' + }); + + const result = await service.updateTicketComment({ + ticketId: mockTicketId, + ticketCommentId: mockTicketCommentId, + comment: 'Updated comment' + }); + + expect(updateTicketCommentStub).to.have.been.calledOnceWith(mockTicketId, mockTicketCommentId, 'Updated comment'); + expect(getTicketCommentByIdStub).to.have.been.calledOnceWith(mockTicketId, mockTicketCommentId); + expect(result.comment).to.equal('Updated comment'); + }); + }); + + describe('deleteTicketCommentByTicketId', () => { + it('verifies ticket linkage before deleting comment link', async () => { + const mockDBConnection = getMockDBConnection(); + const service = new TicketCommentService(mockDBConnection); + + const getByIdStub = sinon + .stub(TicketCommentRepository.prototype, 'getTicketCommentById') + .resolves(mockTicketComment); + const deleteStub = sinon + .stub(TicketCommentRepository.prototype, 'deleteTicketComment') + .resolves({ ticket_comment_id: mockTicketCommentId }); + + await service.deleteTicketCommentByTicketId(mockTicketId, mockTicketCommentId); + + expect(getByIdStub).to.have.been.calledOnceWith(mockTicketId, mockTicketCommentId); + expect(deleteStub).to.have.been.calledOnceWith(mockTicketId, mockTicketCommentId); + }); + }); + + describe('getTicketComments', () => { + it('returns ticket comments from repository', async () => { + const mockDBConnection = getMockDBConnection(); + const service = new TicketCommentService(mockDBConnection); + + const getCommentsStub = sinon + .stub(TicketCommentRepository.prototype, 'getTicketComments') + .resolves([mockTicketComment]); + + const result = await service.getTicketComments(mockTicketId); + + expect(getCommentsStub).to.have.been.calledOnceWith(mockTicketId); + expect(result).to.eql([mockTicketComment]); + }); + }); +}); diff --git a/api/src/services/ticket-comment-service.ts b/api/src/services/ticket-comment-service.ts new file mode 100644 index 000000000..5f80487e4 --- /dev/null +++ b/api/src/services/ticket-comment-service.ts @@ -0,0 +1,91 @@ +import { IDBConnection } from '../database/db'; +import { CreateTicketComment, TicketComment, UpdateTicketComment } from '../models/ticket-comment'; +import { CommentRepository } from '../repositories/comment-repository'; +import { TicketCommentRepository } from '../repositories/ticket-comment-repository'; +import { DBService } from './db-service'; + +/** + * Service for ticket_comment operations. + */ +export class TicketCommentService extends DBService { + commentRepository: CommentRepository; + ticketCommentRepository: TicketCommentRepository; + + /** + * Creates an instance of TicketCommentService. + * + * @param {IDBConnection} connection - Database connection object. + * @memberof TicketCommentService + */ + constructor(connection: IDBConnection) { + super(connection); + this.commentRepository = new CommentRepository(connection); + this.ticketCommentRepository = new TicketCommentRepository(connection); + } + + /** + * Create a comment record and link it to a ticket. + * + * Performs insert-then-get so the returned object matches the read shape. + * + * @param {CreateTicketComment} payload - Ticket comment payload. + * @return {Promise} The created ticket comment row. + * @memberof TicketCommentService + */ + async createTicketComment(payload: CreateTicketComment): Promise { + const comment = await this.commentRepository.createComment(payload.comment); + const insertedTicketComment = await this.ticketCommentRepository.insertTicketComment( + payload.ticketId, + comment.comment_id + ); + return this.ticketCommentRepository.getTicketCommentById(payload.ticketId, insertedTicketComment.ticket_comment_id); + } + + /** + * Soft delete a ticket comment link row. + * + * @param {string} ticketCommentId - Ticket comment UUID. + * @return {Promise} + * @memberof TicketCommentService + */ + async deleteTicketComment(ticketId: string, ticketCommentId: string): Promise { + await this.ticketCommentRepository.deleteTicketComment(ticketId, ticketCommentId); + } + + /** + * Update a ticket comment body. + * + * @param {UpdateTicketComment} payload + * @return {Promise} + * @memberof TicketCommentService + */ + async updateTicketComment(payload: UpdateTicketComment): Promise { + await this.ticketCommentRepository.updateTicketComment(payload.ticketId, payload.ticketCommentId, payload.comment); + + return this.ticketCommentRepository.getTicketCommentById(payload.ticketId, payload.ticketCommentId); + } + + /** + * Soft delete a ticket comment link row scoped to a ticket. + * + * @param {string} ticketId + * @param {string} ticketCommentId + * @return {Promise} + * @memberof TicketCommentService + */ + async deleteTicketCommentByTicketId(ticketId: string, ticketCommentId: string): Promise { + await this.ticketCommentRepository.getTicketCommentById(ticketId, ticketCommentId); + await this.ticketCommentRepository.deleteTicketComment(ticketId, ticketCommentId); + } + + /** + * Get comment rows for a ticket ordered oldest first. + * + * @param {string} ticketId - Ticket UUID. + * @return {Promise} Ticket comment rows. + * @memberof TicketCommentService + */ + async getTicketComments(ticketId: string): Promise { + return this.ticketCommentRepository.getTicketComments(ticketId); + } +} diff --git a/api/src/services/ticket-reference-service.test.ts b/api/src/services/ticket-reference-service.test.ts new file mode 100644 index 000000000..5cd01d376 --- /dev/null +++ b/api/src/services/ticket-reference-service.test.ts @@ -0,0 +1,109 @@ +import chai, { expect } from 'chai'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import { TicketReference } from '../models/ticket-reference'; +import { TicketReferenceRepository } from '../repositories/ticket-reference-repository'; +import { getMockDBConnection } from '../__mocks__/db'; +import { TicketReferenceService } from './ticket-reference-service'; + +chai.use(sinonChai); + +describe('TicketReferenceService', () => { + afterEach(() => { + sinon.restore(); + }); + + const mockSourceTicketId = '11111111-1111-1111-1111-111111111111'; + const mockTargetTicketId = '22222222-2222-2222-2222-222222222222'; + const mockTicketReferenceId = '33333333-3333-3333-3333-333333333333'; + + const mockTicketReference: TicketReference = { + ticket_reference_id: mockTicketReferenceId, + source_ticket_id: mockSourceTicketId, + source_ticket_slug: '04900001', + source_ticket_subject: 'Source ticket', + target_ticket_id: mockTargetTicketId, + target_ticket_slug: '04900002', + target_ticket_subject: 'Target ticket', + relationship: 'relates_to', + user_identifier: 'Sarah', + create_date: '2026-02-25T00:00:00.000Z' + }; + + describe('createTicketReference', () => { + it('creates a reference and returns reference row', async () => { + const mockDBConnection = getMockDBConnection(); + const service = new TicketReferenceService(mockDBConnection); + + const insertStub = sinon + .stub(TicketReferenceRepository.prototype, 'insertTicketReference') + .resolves({ ticket_reference_id: mockTicketReferenceId }); + const getByIdStub = sinon + .stub(TicketReferenceRepository.prototype, 'getTicketReferenceById') + .resolves(mockTicketReference); + + const result = await service.createTicketReference({ + source_ticket_id: mockSourceTicketId, + target_ticket_id: mockTargetTicketId, + relationship: 'relates_to' + }); + + expect(insertStub).to.have.been.calledOnceWith({ + source_ticket_id: mockSourceTicketId, + target_ticket_id: mockTargetTicketId, + relationship: 'relates_to' + }); + expect(getByIdStub).to.have.been.calledOnceWith(mockTicketReferenceId); + expect(result).to.eql(mockTicketReference); + }); + + it('propagates repository errors', async () => { + const mockDBConnection = getMockDBConnection(); + const service = new TicketReferenceService(mockDBConnection); + + sinon.stub(TicketReferenceRepository.prototype, 'insertTicketReference').rejects(new Error('DB error')); + + try { + await service.createTicketReference({ + source_ticket_id: mockSourceTicketId, + target_ticket_id: mockTargetTicketId, + relationship: 'relates_to' + }); + expect.fail('Expected error to be thrown'); + } catch (error) { + expect((error as Error).message).to.equal('DB error'); + } + }); + }); + + describe('deleteTicketReference', () => { + it('delegates delete to repository', async () => { + const mockDBConnection = getMockDBConnection(); + const service = new TicketReferenceService(mockDBConnection); + + const deleteStub = sinon + .stub(TicketReferenceRepository.prototype, 'deleteTicketReference') + .resolves({ ticket_reference_id: mockTicketReferenceId }); + + await service.deleteTicketReference(mockSourceTicketId, mockTicketReferenceId); + + expect(deleteStub).to.have.been.calledOnceWith(mockSourceTicketId, mockTicketReferenceId); + }); + }); + + describe('getTicketReferencesForTicket', () => { + it('returns ticket references from repository', async () => { + const mockDBConnection = getMockDBConnection(); + const service = new TicketReferenceService(mockDBConnection); + + const getReferencesStub = sinon + .stub(TicketReferenceRepository.prototype, 'getTicketReferencesForTicket') + .resolves([mockTicketReference]); + + const result = await service.getTicketReferencesForTicket(mockSourceTicketId); + + expect(getReferencesStub).to.have.been.calledOnceWith(mockSourceTicketId); + expect(result).to.eql([mockTicketReference]); + }); + }); +}); diff --git a/api/src/services/ticket-reference-service.ts b/api/src/services/ticket-reference-service.ts new file mode 100644 index 000000000..309cc8023 --- /dev/null +++ b/api/src/services/ticket-reference-service.ts @@ -0,0 +1,60 @@ +import { IDBConnection } from '../database/db'; +import { CreateTicketReference, TicketReference } from '../models/ticket-reference'; +import { TicketReferenceRepository } from '../repositories/ticket-reference-repository'; +import { DBService } from './db-service'; + +/** + * Service for ticket_reference operations. + */ +export class TicketReferenceService extends DBService { + ticketReferenceRepository: TicketReferenceRepository; + + /** + * Creates an instance of TicketReferenceService. + * + * @param {IDBConnection} connection - Database connection object. + * @memberof TicketReferenceService + */ + constructor(connection: IDBConnection) { + super(connection); + this.ticketReferenceRepository = new TicketReferenceRepository(connection); + } + + /** + * Create a ticket reference row. + * + * Performs insert-then-get so the returned object matches the read shape. + * + * @param {CreateTicketReference} payload - Ticket reference payload. + * @return {Promise} Created ticket reference row. + * @memberof TicketReferenceService + */ + async createTicketReference(payload: CreateTicketReference): Promise { + const inserted = await this.ticketReferenceRepository.insertTicketReference(payload); + + return this.ticketReferenceRepository.getTicketReferenceById(inserted.ticket_reference_id); + } + + /** + * Soft delete a ticket reference row scoped to a ticket. + * + * @param {string} ticketId - Ticket UUID. + * @param {string} ticketReferenceId - Ticket reference UUID. + * @return {Promise} + * @memberof TicketReferenceService + */ + async deleteTicketReference(ticketId: string, ticketReferenceId: string): Promise { + await this.ticketReferenceRepository.deleteTicketReference(ticketId, ticketReferenceId); + } + + /** + * Get active references for a ticket where it is either source or target. + * + * @param {string} ticketId - Ticket UUID. + * @return {Promise} Ticket reference rows. + * @memberof TicketReferenceService + */ + async getTicketReferencesForTicket(ticketId: string): Promise { + return this.ticketReferenceRepository.getTicketReferencesForTicket(ticketId); + } +} diff --git a/api/src/services/ticket-service.test.ts b/api/src/services/ticket-service.test.ts new file mode 100644 index 000000000..99c4f41ed --- /dev/null +++ b/api/src/services/ticket-service.test.ts @@ -0,0 +1,254 @@ +import chai, { expect } from 'chai'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import { IDBConnection } from '../database/db'; +import { Team } from '../models/team'; +import { Ticket, TicketFilters } from '../models/ticket'; +import { TicketReference } from '../models/ticket-reference'; +import { TicketStatus } from '../models/ticket-status'; +import { TicketCommentRepository } from '../repositories/ticket-comment-repository'; +import { TicketRepository } from '../repositories/ticket-repository'; +import { getMockDBConnection } from '../__mocks__/db'; +import { TeamService } from './access-policy/team-service'; +import { TicketReferenceService } from './ticket-reference-service'; +import { TicketService } from './ticket-service'; +import { TicketStatusService } from './ticket-status-service'; + +chai.use(sinonChai); + +describe('TicketService', () => { + let mockDBConnection: IDBConnection; + let service: TicketService; + + const mockTicket: Ticket = { + ticket_id: '11111111-1111-1111-1111-111111111111', + ticket_slug: '04900001', + subject: 'A ticket', + description: 'desc', + team_id: '22222222-2222-2222-2222-222222222222', + create_date: '2026-02-25T00:00:00.000Z', + priority: 'medium', + status: 'open' + }; + + beforeEach(() => { + mockDBConnection = getMockDBConnection(); + service = new TicketService(mockDBConnection); + }); + + afterEach(() => { + sinon.restore(); + }); + + describe('createTicket', () => { + it('creates team, ticket and initial status history', async () => { + const generatedTeamId = '99999999-9999-9999-9999-999999999999'; + const createdTicket = { ...mockTicket, team_id: generatedTeamId }; + const getNextTicketSlugStub = sinon.stub(TicketRepository.prototype, 'getNextTicketSlug').resolves('04900001'); + const mockTeam: Team = { + team_id: generatedTeamId, + name: 'Auto Team', + description: null, + member_count: 0 + }; + const createTeamWithMembersStub = sinon.stub(TeamService.prototype, 'createTeam').resolves(mockTeam); + const insertTicketStub = sinon.stub(TicketRepository.prototype, 'insertTicket').resolves(createdTicket); + const insertHistoryStub = sinon.stub(TicketStatusService.prototype, 'insertTicketStatus').resolves(); + + const result = await service.createTicket({ subject: 'A ticket', description: null, priority: 'medium' }); + + expect(createTeamWithMembersStub).to.have.been.calledWith( + sinon.match({ + name: sinon.match.string, + description: 'Auto-generated team for ticket assignees.', + system_user_ids: [] + }) + ); + expect(getNextTicketSlugStub).to.have.been.calledOnce; + expect(insertTicketStub).to.have.been.calledWith( + sinon.match({ + subject: 'A ticket', + description: null, + priority: 'medium', + team_id: generatedTeamId, + ticket_slug: sinon.match(/^\d{8}$/) + }) + ); + expect(insertHistoryStub).to.have.been.calledWith(createdTicket.ticket_id, 'open'); + expect(result).to.eql(createdTicket); + }); + + it('throws when insert fails', async () => { + sinon.stub(TicketRepository.prototype, 'getNextTicketSlug').resolves('04900001'); + const mockTeam: Team = { + team_id: mockTicket.team_id, + name: 'Auto Team', + description: null, + member_count: 0 + }; + sinon.stub(TeamService.prototype, 'createTeam').resolves(mockTeam); + const insertError = new Error('insert failed'); + sinon.stub(TicketRepository.prototype, 'insertTicket').rejects(insertError); + const insertHistoryStub = sinon.stub(TicketStatusService.prototype, 'insertTicketStatus').resolves(); + + try { + await service.createTicket({ subject: 'A ticket', description: 'desc', priority: 'medium' }); + expect.fail(); + } catch (error) { + expect(error).to.equal(insertError); + expect(insertHistoryStub).to.not.have.been.called; + } + }); + }); + + describe('getTickets / getTicketsCount', () => { + it('delegates to repository', async () => { + const listStub = sinon.stub(TicketRepository.prototype, 'getTickets').resolves([mockTicket]); + const countStub = sinon.stub(TicketRepository.prototype, 'getTicketsCount').resolves(1); + + const filters: TicketFilters = { team_id: mockTicket.team_id, status: 'open' }; + const list = await service.getTickets(filters, { page: 1, limit: 10 }); + const count = await service.getTicketsCount(filters); + + expect(listStub).to.have.been.calledWith(filters, { page: 1, limit: 10 }); + expect(countStub).to.have.been.calledWith(filters); + expect(list).to.eql([mockTicket]); + expect(count).to.equal(1); + }); + }); + + describe('getTicket', () => { + it('returns ticket payload with separate status and comment logs when resolved by UUID', async () => { + const statusLog: TicketStatus[] = [ + { + ticket_status_id: '33333333-3333-3333-3333-333333333333', + ticket_id: mockTicket.ticket_id, + user_identifier: 'Sarah', + create_date: '2026-02-25T00:00:00.000Z', + status: 'open' + } + ]; + const commentLog = [ + { + ticket_comment_id: '44444444-4444-4444-4444-444444444444', + ticket_id: mockTicket.ticket_id, + user_identifier: 'Bob', + create_date: '2026-02-25T01:00:00.000Z', + comment: 'New comment' + } + ]; + const referenceLog: TicketReference[] = [ + { + ticket_reference_id: '55555555-5555-5555-5555-555555555555', + source_ticket_id: mockTicket.ticket_id, + source_ticket_slug: mockTicket.ticket_slug, + source_ticket_subject: mockTicket.subject, + target_ticket_id: '66666666-6666-6666-6666-666666666666', + target_ticket_slug: '04900002', + target_ticket_subject: 'Related ticket', + relationship: 'relates_to', + user_identifier: 'Bob', + create_date: '2026-02-25T02:00:00.000Z' + } + ]; + const getTicketStub = sinon.stub(TicketRepository.prototype, 'getTicketById').resolves(mockTicket); + const getStatusLogStub = sinon.stub(TicketStatusService.prototype, 'getTicketStatus').resolves(statusLog); + const getCommentLogStub = sinon.stub(TicketCommentRepository.prototype, 'getTicketComments').resolves(commentLog); + const getReferenceLogStub = sinon + .stub(TicketReferenceService.prototype, 'getTicketReferencesForTicket') + .resolves(referenceLog); + + const result = await service.getTicket(mockTicket.ticket_id); + + expect(getTicketStub).to.have.been.calledWith(mockTicket.ticket_id); + expect(getStatusLogStub).to.have.been.calledWith(mockTicket.ticket_id); + expect(getCommentLogStub).to.have.been.calledWith(mockTicket.ticket_id); + expect(getReferenceLogStub).to.have.been.calledWith(mockTicket.ticket_id); + expect(result).to.eql({ + ...mockTicket, + statuses: statusLog, + comments: commentLog, + references: referenceLog + }); + }); + }); + + describe('updateTicket', () => { + it('delegates updates to repository', async () => { + const updated = { ...mockTicket, subject: 'new subject' }; + const updateStub = sinon.stub(TicketRepository.prototype, 'updateTicket').resolves(updated); + const historyStub = sinon.stub(TicketStatusService.prototype, 'insertTicketStatus').resolves(); + + const result = await service.updateTicket(mockTicket.ticket_id, { subject: 'new subject' }); + + expect(updateStub).to.have.been.calledWith(mockTicket.ticket_id, { subject: 'new subject' }); + expect(historyStub).to.not.have.been.called; + expect(result).to.eql(updated); + }); + + it('delegates status updates to repository', async () => { + const updated: Ticket = { ...mockTicket, status: 'closed' }; + const updateStub = sinon.stub(TicketRepository.prototype, 'updateTicket').resolves(updated); + const historyStub = sinon.stub(TicketStatusService.prototype, 'insertTicketStatus').resolves(); + + const result = await service.updateTicket(mockTicket.ticket_id, { status: 'closed' }); + + expect(updateStub).to.have.been.calledWith(mockTicket.ticket_id, { status: 'closed' }); + expect(historyStub).to.have.been.calledWith(mockTicket.ticket_id, 'closed'); + expect(result).to.eql(updated); + }); + }); + + describe('deleteTicket', () => { + it('soft deletes an active ticket', async () => { + const deleteStub = sinon.stub(TicketRepository.prototype, 'deleteTicket').resolves(mockTicket); + + await service.deleteTicket(mockTicket.ticket_id); + + expect(deleteStub).to.have.been.calledWith(mockTicket.ticket_id); + }); + }); + + describe('createTicketReference', () => { + it('creates a ticket reference for the source ticket', async () => { + const createdReference: TicketReference = { + ticket_reference_id: '77777777-7777-7777-7777-777777777777', + source_ticket_id: mockTicket.ticket_id, + source_ticket_slug: mockTicket.ticket_slug, + source_ticket_subject: mockTicket.subject, + target_ticket_id: '88888888-8888-8888-8888-888888888888', + target_ticket_slug: '04900003', + target_ticket_subject: 'Another ticket', + relationship: 'relates_to', + user_identifier: 'Sarah', + create_date: '2026-02-25T00:00:00.000Z' + }; + const createReferenceStub = sinon + .stub(TicketReferenceService.prototype, 'createTicketReference') + .resolves(createdReference); + + const result = await service.createTicketReference(mockTicket.ticket_id, { + target_ticket_id: createdReference.target_ticket_id, + relationship: createdReference.relationship + }); + + expect(createReferenceStub).to.have.been.calledWith({ + source_ticket_id: mockTicket.ticket_id, + target_ticket_id: createdReference.target_ticket_id, + relationship: createdReference.relationship + }); + expect(result).to.eql(createdReference); + }); + }); + + describe('deleteTicketReference', () => { + it('deletes a ticket reference by id', async () => { + const ticketReferenceId = '77777777-7777-7777-7777-777777777777'; + const deleteReferenceStub = sinon.stub(TicketReferenceService.prototype, 'deleteTicketReference').resolves(); + + await service.deleteTicketReference(mockTicket.ticket_id, ticketReferenceId); + + expect(deleteReferenceStub).to.have.been.calledWith(mockTicket.ticket_id, ticketReferenceId); + }); + }); +}); diff --git a/api/src/services/ticket-service.ts b/api/src/services/ticket-service.ts new file mode 100644 index 000000000..51b27492a --- /dev/null +++ b/api/src/services/ticket-service.ts @@ -0,0 +1,184 @@ +import { v4 } from 'uuid'; +import { IDBConnection } from '../database/db'; +import { HTTP400 } from '../errors/http-error'; +import { Team } from '../models/team'; +import { CreateTicketRequest, Ticket, TicketFilters, TicketWithHistory, UpdateTicketRequest } from '../models/ticket'; +import { CreateTicketReferenceRequest, TicketReference } from '../models/ticket-reference'; +import { TicketRepository } from '../repositories/ticket-repository'; +import { ApiPaginationOptions } from '../zod-schema/pagination'; +import { TeamService } from './access-policy/team-service'; +import { DBService } from './db-service'; +import { TicketCommentService } from './ticket-comment-service'; +import { TicketReferenceService } from './ticket-reference-service'; +import { TicketStatusService } from './ticket-status-service'; + +export class TicketService extends DBService { + teamService: TeamService; + ticketRepository: TicketRepository; + ticketCommentService: TicketCommentService; + ticketStatusService: TicketStatusService; + ticketReferenceService: TicketReferenceService; + + /** + * Creates an instance of TicketService. + * + * @param {IDBConnection} connection - Database connection object. + * @memberof TicketService + */ + constructor(connection: IDBConnection) { + super(connection); + this.teamService = new TeamService(connection); + this.ticketRepository = new TicketRepository(connection); + this.ticketCommentService = new TicketCommentService(connection); + this.ticketStatusService = new TicketStatusService(connection); + this.ticketReferenceService = new TicketReferenceService(connection); + } + + /** + * Create a new ticket and write its initial status entry. + * + * @param {CreateTicketRequest} ticket - Ticket payload to create. + * @return {Promise} The newly created ticket. + * @memberof TicketService + */ + async createTicket(ticket: CreateTicketRequest): Promise { + const [team, slug] = await Promise.all([this.createTicketTeam(), this.ticketRepository.getNextTicketSlug()]); + + const createdTicket = await this.ticketRepository.insertTicket({ + ...ticket, + team_id: team.team_id, + ticket_slug: slug + }); + + await this.ticketStatusService.insertTicketStatus(createdTicket.ticket_id, createdTicket.status); + + return createdTicket; + } + + /** + * Create an internal team record for ticket ownership. + * + * @return {*} {Promise} + * @memberof TicketService + */ + private async createTicketTeam(): Promise { + const team = await this.teamService.createTeam({ + name: `Ticket Team ${v4()}`, + description: 'Auto-generated team for ticket assignees.', + system_user_ids: [] + }); + + return team; + } + + /** + * Get a ticket by its UUID with separate status and comment logs. + * + * @param {string} ticketId - Ticket UUID. + * @return {Promise} The requested ticket including status and comment logs. + * @memberof TicketService + */ + async getTicket(ticketId: string): Promise { + const [ticket, statuses, comments, references] = await Promise.all([ + this.ticketRepository.getTicketById(ticketId), + this.ticketStatusService.getTicketStatus(ticketId), + this.ticketCommentService.getTicketComments(ticketId), + this.ticketReferenceService.getTicketReferencesForTicket(ticketId) + ]); + + return { ...ticket, statuses, comments, references }; + } + + /** + * Add a reference linking this ticket to another ticket. + * + * @param {string} ticketId - Source ticket UUID. + * @param {CreateTicketReferenceRequest} payload - Ticket reference payload. + * @return {Promise} Created ticket reference. + * @memberof TicketService + */ + async createTicketReference(ticketId: string, payload: CreateTicketReferenceRequest): Promise { + return this.ticketReferenceService.createTicketReference({ + source_ticket_id: ticketId, + target_ticket_id: payload.target_ticket_id, + relationship: payload.relationship + }); + } + + /** + * Delete a ticket reference by identifier. + * + * @param {string} ticketId - Ticket UUID. + * @param {string} ticketReferenceId - Ticket reference UUID. + * @return {Promise} + * @memberof TicketService + */ + async deleteTicketReference(ticketId: string, ticketReferenceId: string): Promise { + await this.ticketReferenceService.deleteTicketReference(ticketId, ticketReferenceId); + } + + /** + * List tickets with optional filters. + * + * @param {TicketFilters} [filters] - Optional ticket list filters. + * @param {ApiPaginationOptions} [pagination] - Optional pagination options. + * @return {Promise} Matching tickets. + * @memberof TicketService + */ + async getTickets(filters?: TicketFilters, pagination?: ApiPaginationOptions): Promise { + return this.ticketRepository.getTickets(filters, pagination); + } + + /** + * Count tickets with optional filters. + * + * @param {TicketFilters} [filters] - Optional ticket list filters. + * @return {Promise} Total count of matching tickets. + * @memberof TicketService + */ + async getTicketsCount(filters?: TicketFilters): Promise { + return this.ticketRepository.getTicketsCount(filters); + } + + /** + * Update ticket fields, including status when provided. + * + * @param {string} ticketId - Ticket UUID. + * @param {UpdateTicketRequest} ticket - Partial ticket update payload. + * @return {Promise} Updated ticket record. + * @memberof TicketService + */ + async updateTicket(ticketId: string, ticket: UpdateTicketRequest): Promise { + const updates = Object.fromEntries( + Object.entries({ + subject: ticket.subject, + description: ticket.description, + priority: ticket.priority, + status: ticket.status + }).filter(([, value]) => value !== undefined) + ); + + if (Object.keys(updates).length === 0) { + throw new HTTP400('No fields provided for update'); + } + + const updatedTicket = await this.ticketRepository.updateTicket(ticketId, updates); + + if (ticket.status !== undefined) { + await this.ticketStatusService.insertTicketStatus(ticketId, ticket.status); + } + + return updatedTicket; + } + + /** + * Soft delete an active ticket. + * + * @param {string} ticketId - Ticket UUID. + * @return {Promise} + * @memberof TicketService + */ + async deleteTicket(ticketId: string): Promise { + await this.ticketRepository.deleteTicket(ticketId); + } +} diff --git a/api/src/services/ticket-status-service.test.ts b/api/src/services/ticket-status-service.test.ts new file mode 100644 index 000000000..a057c0d8c --- /dev/null +++ b/api/src/services/ticket-status-service.test.ts @@ -0,0 +1,67 @@ +import chai, { expect } from 'chai'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import { TicketStatus } from '../models/ticket-status'; +import { TicketStatusRepository } from '../repositories/ticket-status-repository'; +import { getMockDBConnection } from '../__mocks__/db'; +import { TicketStatusService } from './ticket-status-service'; + +chai.use(sinonChai); + +describe('TicketStatusService', () => { + afterEach(() => { + sinon.restore(); + }); + + const mockTicketId = '11111111-1111-1111-1111-111111111111'; + + const mockStatusRow: TicketStatus = { + ticket_status_id: '33333333-3333-3333-3333-333333333333', + ticket_id: mockTicketId, + user_identifier: 'Sarah', + create_date: '2026-02-25T00:00:00.000Z', + status: 'open' + }; + + describe('insertTicketStatus', () => { + it('delegates insert to repository', async () => { + const mockDBConnection = getMockDBConnection(); + const service = new TicketStatusService(mockDBConnection); + + const insertStub = sinon.stub(TicketStatusRepository.prototype, 'insertTicketStatus').resolves(mockStatusRow); + + const result = await service.insertTicketStatus(mockTicketId, 'open'); + + expect(insertStub).to.have.been.calledOnceWith(mockTicketId, 'open'); + expect(result).to.eql(mockStatusRow); + }); + + it('propagates repository errors', async () => { + const mockDBConnection = getMockDBConnection(); + const service = new TicketStatusService(mockDBConnection); + + sinon.stub(TicketStatusRepository.prototype, 'insertTicketStatus').rejects(new Error('DB error')); + + try { + await service.insertTicketStatus(mockTicketId, 'open'); + expect.fail('Expected error to be thrown'); + } catch (error) { + expect((error as Error).message).to.equal('DB error'); + } + }); + }); + + describe('getTicketStatus', () => { + it('returns status rows from repository', async () => { + const mockDBConnection = getMockDBConnection(); + const service = new TicketStatusService(mockDBConnection); + + const getStub = sinon.stub(TicketStatusRepository.prototype, 'getTicketStatus').resolves([mockStatusRow]); + + const result = await service.getTicketStatus(mockTicketId); + + expect(getStub).to.have.been.calledOnceWith(mockTicketId); + expect(result).to.eql([mockStatusRow]); + }); + }); +}); diff --git a/api/src/services/ticket-status-service.ts b/api/src/services/ticket-status-service.ts new file mode 100644 index 000000000..352c7ec0f --- /dev/null +++ b/api/src/services/ticket-status-service.ts @@ -0,0 +1,46 @@ +import { IDBConnection } from '../database/db'; +import { TicketStatus as TicketStatusEnum } from '../models/ticket'; +import { TicketStatus } from '../models/ticket-status'; +import { TicketStatusRepository } from '../repositories/ticket-status-repository'; +import { DBService } from './db-service'; + +/** + * Service for ticket_status operations. + */ +export class TicketStatusService extends DBService { + ticketStatusRepository: TicketStatusRepository; + + /** + * Creates an instance of TicketStatusService. + * + * @param {IDBConnection} connection - Database connection object. + * @memberof TicketStatusService + */ + constructor(connection: IDBConnection) { + super(connection); + this.ticketStatusRepository = new TicketStatusRepository(connection); + } + + /** + * Insert a status transition row for a ticket. + * + * @param {string} ticketId - Ticket UUID. + * @param {TicketStatusEnum} status - Status value to append. + * @return {Promise} Created status row. + * @memberof TicketStatusService + */ + async insertTicketStatus(ticketId: string, status: TicketStatusEnum): Promise { + return this.ticketStatusRepository.insertTicketStatus(ticketId, status); + } + + /** + * Get status history for a ticket + * + * @param {string} ticketId - Ticket UUID. + * @return {Promise} Status rows. + * @memberof TicketStatusService + */ + async getTicketStatus(ticketId: string): Promise { + return this.ticketStatusRepository.getTicketStatus(ticketId); + } +} diff --git a/database/src/migrations/20260213122400_ticket.ts b/database/src/migrations/20260213122400_ticket.ts new file mode 100644 index 000000000..d97b7f0cf --- /dev/null +++ b/database/src/migrations/20260213122400_ticket.ts @@ -0,0 +1,292 @@ +import type { Knex } from 'knex'; + +export async function up(knex: Knex): Promise { + await knex.raw(` + SET SEARCH_PATH = biohub, public; + + -------------------------------------------------------------------------------- + -- ENUMS + -------------------------------------------------------------------------------- + + CREATE TYPE ticket_priority AS ENUM ( + 'low', + 'medium', + 'high', + 'critical' + ); + + COMMENT ON TYPE ticket_priority IS 'Priority levels for tickets.'; + + CREATE TYPE ticket_relationship_type AS ENUM ( + 'blocks', + 'blocked_by', + 'duplicates', + 'duplicate_of', + 'relates_to', + 'resolves', + 'resolved_by' + ); + + COMMENT ON TYPE ticket_relationship_type IS 'Type of relationship between source and target ticket.'; + + CREATE TYPE ticket_status_type AS ENUM ( + 'open', + 'closed' + ); + + COMMENT ON TYPE ticket_status_type IS 'Lifecycle status for tickets (open or closed).'; + + -------------------------------------------------------------------------------- + -- TICKET + -------------------------------------------------------------------------------- + + CREATE TABLE ticket ( + ticket_id uuid DEFAULT gen_random_uuid() NOT NULL, + ticket_slug varchar(8) NOT NULL, + subject varchar(100) NOT NULL, + description varchar(2000) NULL, + team_id uuid NOT NULL, + priority ticket_priority NOT NULL DEFAULT 'medium', + status ticket_status_type NOT NULL DEFAULT 'open', + record_end_date timestamptz(6), + create_date timestamptz(6) DEFAULT now() NOT NULL, + create_user integer NOT NULL, + update_date timestamptz(6), + update_user integer, + revision_count integer DEFAULT 0 NOT NULL, + CONSTRAINT ticket_pk PRIMARY KEY (ticket_id), + CONSTRAINT ticket_team_fk FOREIGN KEY (team_id) REFERENCES team(team_id), + CONSTRAINT ticket_slug_unique UNIQUE (ticket_slug), + CONSTRAINT ticket_slug_format_chk CHECK (ticket_slug ~ '^[0-9]{8}$') + ); + + CREATE INDEX ticket_team_idx ON ticket(team_id); + CREATE INDEX ticket_priority_idx ON ticket(priority); + CREATE INDEX ticket_active_team_status_idx + ON ticket(team_id, status, create_date DESC) + WHERE record_end_date IS NULL; + CREATE INDEX ticket_active_status_idx + ON ticket(status, create_date DESC) + WHERE record_end_date IS NULL; + CREATE INDEX ticket_open_team_idx + ON ticket(team_id, create_date DESC) + WHERE record_end_date IS NULL AND status = 'open'; + + COMMENT ON TABLE ticket IS 'Coordination ticket for admin actions requiring review. Each ticket has a unique slug and URL. Access controlled by team membership - team members and system admins can view.'; + COMMENT ON COLUMN ticket.ticket_id IS 'System generated surrogate primary key identifier.'; + COMMENT ON COLUMN ticket.ticket_slug IS '8-digit slug in DDDNNNNN format where DDD is UTC day-of-year and NNNNN is per-day sequence.'; + COMMENT ON COLUMN ticket.subject IS 'Brief title describing the ticket purpose.'; + COMMENT ON COLUMN ticket.description IS 'Detailed description of what this ticket is for.'; + COMMENT ON COLUMN ticket.team_id IS 'Foreign key to the team. Determines access control - team members and system admins can view this ticket.'; + COMMENT ON COLUMN ticket.priority IS 'Priority level: low, medium, high, critical.'; + COMMENT ON COLUMN ticket.status IS 'Authoritative lifecycle state of the ticket.'; + COMMENT ON COLUMN ticket.record_end_date IS 'Timestamp for soft delete; null when record is active.'; + COMMENT ON COLUMN ticket.create_date IS 'The datetime the record was created.'; + COMMENT ON COLUMN ticket.create_user IS 'The id of the user who created the record.'; + COMMENT ON COLUMN ticket.update_date IS 'The datetime the record was last updated.'; + COMMENT ON COLUMN ticket.update_user IS 'The id of the user who last updated the record.'; + COMMENT ON COLUMN ticket.revision_count IS 'Revision count used for concurrency control.'; + + -------------------------------------------------------------------------------- + -- TICKET STATUS (Immutable append-only status transition log) + -------------------------------------------------------------------------------- + + CREATE TABLE ticket_status ( + ticket_status_id uuid DEFAULT gen_random_uuid() NOT NULL, + ticket_id uuid NOT NULL, + status ticket_status_type NOT NULL, + record_end_date timestamptz(6), + create_date timestamptz(6) DEFAULT now() NOT NULL, + create_user integer NOT NULL, + update_date timestamptz(6), + update_user integer, + revision_count integer DEFAULT 0 NOT NULL, + CONSTRAINT ticket_status_pk PRIMARY KEY (ticket_status_id), + CONSTRAINT ticket_status_ticket_fk FOREIGN KEY (ticket_id) REFERENCES ticket(ticket_id) + ); + + CREATE INDEX ticket_status_ticket_date_idx + ON ticket_status(ticket_id, create_date DESC); + + COMMENT ON TABLE ticket_status IS 'Immutable append-only log of ticket status transitions.'; + COMMENT ON COLUMN ticket_status.ticket_status_id IS 'System generated surrogate primary key identifier.'; + COMMENT ON COLUMN ticket_status.ticket_id IS 'Foreign key to the ticket.'; + COMMENT ON COLUMN ticket_status.status IS 'Ticket status after the transition.'; + COMMENT ON COLUMN ticket_status.record_end_date IS 'Timestamp for soft delete; null when record is active.'; + COMMENT ON COLUMN ticket_status.create_date IS 'The datetime the record was created.'; + COMMENT ON COLUMN ticket_status.create_user IS 'The id of the user who created the record.'; + COMMENT ON COLUMN ticket_status.update_date IS 'The datetime the record was last updated.'; + COMMENT ON COLUMN ticket_status.update_user IS 'The id of the user who last updated the record.'; + COMMENT ON COLUMN ticket_status.revision_count IS 'Revision count used for concurrency control.'; + + -------------------------------------------------------------------------------- + -- TICKET COMMENT (Links tickets to comments) + -------------------------------------------------------------------------------- + + CREATE TABLE ticket_comment ( + ticket_comment_id uuid DEFAULT gen_random_uuid() NOT NULL, + ticket_id uuid NOT NULL, + comment_id uuid NOT NULL, + record_end_date timestamptz(6), + create_date timestamptz(6) DEFAULT now() NOT NULL, + create_user integer NOT NULL, + update_date timestamptz(6), + update_user integer, + revision_count integer DEFAULT 0 NOT NULL, + CONSTRAINT ticket_comment_pk PRIMARY KEY (ticket_comment_id), + CONSTRAINT ticket_comment_ticket_fk FOREIGN KEY (ticket_id) REFERENCES ticket(ticket_id), + CONSTRAINT ticket_comment_comment_fk FOREIGN KEY (comment_id) REFERENCES comment(comment_id) + ); + + CREATE INDEX ticket_comment_ticket_idx ON ticket_comment(ticket_id); + CREATE INDEX ticket_comment_comment_idx ON ticket_comment(comment_id); + CREATE INDEX ticket_comment_create_date_idx ON ticket_comment(create_date); + CREATE INDEX ticket_comment_active_ticket_date_idx + ON ticket_comment(ticket_id, create_date ASC) + WHERE record_end_date IS NULL; + + COMMENT ON TABLE ticket_comment IS 'Links tickets to comments for discussion threads. Comments appear in ticket timeline.'; + COMMENT ON COLUMN ticket_comment.ticket_comment_id IS 'System generated surrogate primary key identifier.'; + COMMENT ON COLUMN ticket_comment.ticket_id IS 'Foreign key to the ticket.'; + COMMENT ON COLUMN ticket_comment.comment_id IS 'Foreign key to the comment.'; + COMMENT ON COLUMN ticket_comment.record_end_date IS 'Timestamp for soft delete; null when record is active.'; + COMMENT ON COLUMN ticket_comment.create_date IS 'The datetime the record was created.'; + COMMENT ON COLUMN ticket_comment.create_user IS 'The id of the user who created the record.'; + COMMENT ON COLUMN ticket_comment.update_date IS 'The datetime the record was last updated.'; + COMMENT ON COLUMN ticket_comment.update_user IS 'The id of the user who last updated the record.'; + COMMENT ON COLUMN ticket_comment.revision_count IS 'Revision count used for concurrency control.'; + + -------------------------------------------------------------------------------- + -- TICKET REFERENCE (Cross-references between tickets) + -------------------------------------------------------------------------------- + + CREATE TABLE ticket_reference ( + ticket_reference_id uuid DEFAULT gen_random_uuid() NOT NULL, + source_ticket_id uuid NOT NULL, + target_ticket_id uuid NOT NULL, + relationship ticket_relationship_type NOT NULL, + record_end_date timestamptz(6), + create_date timestamptz(6) DEFAULT now() NOT NULL, + create_user integer NOT NULL, + update_date timestamptz(6), + update_user integer, + revision_count integer DEFAULT 0 NOT NULL, + CONSTRAINT ticket_reference_pk PRIMARY KEY (ticket_reference_id), + CONSTRAINT ticket_reference_source_fk FOREIGN KEY (source_ticket_id) REFERENCES ticket(ticket_id), + CONSTRAINT ticket_reference_target_fk FOREIGN KEY (target_ticket_id) REFERENCES ticket(ticket_id), + CONSTRAINT ticket_reference_no_self_reference CHECK (source_ticket_id <> target_ticket_id) + ); + + CREATE INDEX ticket_reference_source_idx ON ticket_reference(source_ticket_id); + CREATE INDEX ticket_reference_target_idx ON ticket_reference(target_ticket_id); + CREATE INDEX ticket_reference_active_source_date_idx + ON ticket_reference(source_ticket_id, create_date ASC) + WHERE record_end_date IS NULL; + CREATE INDEX ticket_reference_active_target_date_idx + ON ticket_reference(target_ticket_id, create_date ASC) + WHERE record_end_date IS NULL; + CREATE INDEX ticket_reference_relationship_idx ON ticket_reference(relationship); + CREATE UNIQUE INDEX ticket_reference_active_unique_idx + ON ticket_reference(source_ticket_id, target_ticket_id, relationship) + WHERE record_end_date IS NULL; + + COMMENT ON TABLE ticket_reference IS 'Directional relationships between tickets showing how tickets relate to each other.'; + COMMENT ON COLUMN ticket_reference.ticket_reference_id IS 'System generated surrogate primary key identifier.'; + COMMENT ON COLUMN ticket_reference.source_ticket_id IS 'The source ticket in the relationship.'; + COMMENT ON COLUMN ticket_reference.target_ticket_id IS 'The target ticket in the relationship.'; + COMMENT ON COLUMN ticket_reference.relationship IS 'The type of relationship from source to target (e.g., source blocks target, source duplicates target).'; + COMMENT ON COLUMN ticket_reference.record_end_date IS 'Timestamp for soft delete; null when record is active.'; + COMMENT ON COLUMN ticket_reference.create_date IS 'The datetime the record was created.'; + COMMENT ON COLUMN ticket_reference.create_user IS 'The id of the user who created the record.'; + COMMENT ON COLUMN ticket_reference.update_date IS 'The datetime the record was last updated.'; + COMMENT ON COLUMN ticket_reference.update_user IS 'The id of the user who last updated the record.'; + COMMENT ON COLUMN ticket_reference.revision_count IS 'Revision count used for concurrency control.'; + + -------------------------------------------------------------------------------- + -- AUDIT / JOURNAL TRIGGERS + -------------------------------------------------------------------------------- + + CREATE TRIGGER audit_ticket + BEFORE INSERT OR UPDATE OR DELETE ON ticket + FOR EACH ROW EXECUTE PROCEDURE biohub.tr_audit_trigger(); + + CREATE TRIGGER journal_ticket + AFTER INSERT OR UPDATE OR DELETE ON ticket + FOR EACH ROW EXECUTE PROCEDURE biohub.tr_journal_trigger(); + + CREATE TRIGGER audit_ticket_status + BEFORE INSERT OR UPDATE OR DELETE ON ticket_status + FOR EACH ROW EXECUTE PROCEDURE biohub.tr_audit_trigger(); + + CREATE TRIGGER journal_ticket_status + AFTER INSERT OR UPDATE OR DELETE ON ticket_status + FOR EACH ROW EXECUTE PROCEDURE biohub.tr_journal_trigger(); + + CREATE TRIGGER audit_ticket_comment + BEFORE INSERT OR UPDATE OR DELETE ON ticket_comment + FOR EACH ROW EXECUTE PROCEDURE biohub.tr_audit_trigger(); + + CREATE TRIGGER journal_ticket_comment + AFTER INSERT OR UPDATE OR DELETE ON ticket_comment + FOR EACH ROW EXECUTE PROCEDURE biohub.tr_journal_trigger(); + + CREATE TRIGGER audit_ticket_reference + BEFORE INSERT OR UPDATE OR DELETE ON ticket_reference + FOR EACH ROW EXECUTE PROCEDURE biohub.tr_audit_trigger(); + + CREATE TRIGGER journal_ticket_reference + AFTER INSERT OR UPDATE OR DELETE ON ticket_reference + FOR EACH ROW EXECUTE PROCEDURE biohub.tr_journal_trigger(); + + `); +} + +export async function down(knex: Knex): Promise { + await knex.raw(` + SET SEARCH_PATH = biohub, public; + + -- Drop audit and journal triggers + DROP TRIGGER IF EXISTS journal_ticket_status ON ticket_status; + DROP TRIGGER IF EXISTS audit_ticket_status ON ticket_status; + DROP TRIGGER IF EXISTS journal_ticket_reference ON ticket_reference; + DROP TRIGGER IF EXISTS audit_ticket_reference ON ticket_reference; + DROP TRIGGER IF EXISTS journal_ticket_comment ON ticket_comment; + DROP TRIGGER IF EXISTS audit_ticket_comment ON ticket_comment; + DROP TRIGGER IF EXISTS journal_ticket ON ticket; + DROP TRIGGER IF EXISTS audit_ticket ON ticket; + + -- Drop indexes + DROP INDEX IF EXISTS ticket_reference_active_unique_idx; + DROP INDEX IF EXISTS ticket_reference_relationship_idx; + DROP INDEX IF EXISTS ticket_reference_active_target_date_idx; + DROP INDEX IF EXISTS ticket_reference_active_source_date_idx; + DROP INDEX IF EXISTS ticket_reference_target_idx; + DROP INDEX IF EXISTS ticket_reference_source_idx; + DROP INDEX IF EXISTS ticket_comment_active_ticket_date_idx; + DROP INDEX IF EXISTS ticket_comment_create_date_idx; + DROP INDEX IF EXISTS ticket_comment_comment_idx; + DROP INDEX IF EXISTS ticket_comment_ticket_idx; + DROP INDEX IF EXISTS ticket_status_ticket_date_idx; + DROP INDEX IF EXISTS ticket_active_status_idx; + DROP INDEX IF EXISTS ticket_open_team_idx; + DROP INDEX IF EXISTS ticket_active_team_status_idx; + DROP INDEX IF EXISTS ticket_priority_idx; + DROP INDEX IF EXISTS ticket_team_idx; + + -- Drop tables + DROP TABLE IF EXISTS ticket_status; + + -- Drop ticket tables + ALTER TABLE ticket DROP COLUMN IF EXISTS status; + ALTER TABLE ticket DROP COLUMN IF EXISTS ticket_slug; + DROP TABLE IF EXISTS ticket_reference; + DROP TABLE IF EXISTS ticket_comment; + DROP TABLE IF EXISTS ticket; + + -- Drop all enums + DROP TYPE IF EXISTS ticket_status_type; + DROP TYPE IF EXISTS ticket_relationship_type; + DROP TYPE IF EXISTS ticket_priority; + + `); +} diff --git a/database/src/seeds/08_ticket_data.ts b/database/src/seeds/08_ticket_data.ts new file mode 100644 index 000000000..2af28a1d0 --- /dev/null +++ b/database/src/seeds/08_ticket_data.ts @@ -0,0 +1,329 @@ +import { Knex } from 'knex'; + +type TicketStatus = 'open' | 'closed'; +type TicketPriority = 'low' | 'medium' | 'high' | 'critical'; +type TicketRelationshipType = + | 'blocks' + | 'blocked_by' + | 'duplicates' + | 'duplicate_of' + | 'relates_to' + | 'resolves' + | 'resolved_by'; + +interface TicketScenario { + key: string; + subject: string; + description: string; + priority: TicketPriority; + currentStatus: TicketStatus; + statusTimeline: TicketStatus[]; + comments: string[]; +} + +interface TicketReferenceScenario { + sourceKey: string; + targetKey: string; + relationship: TicketRelationshipType; +} + +interface EnsureTicketInput { + subject: string; + description: string; + priority: TicketPriority; + status: TicketStatus; + teamId: string; + createUser: number; +} + +const TICKET_SCENARIOS: TicketScenario[] = [ + { + key: 'ops-check', + subject: 'Ops Health Check', + description: 'Validate baseline ticket workflow behavior for active operations tickets.', + priority: 'medium', + currentStatus: 'open', + statusTimeline: ['open'], + comments: ['[Ticket Seed] Ops triage started.', '[Ticket Seed] Awaiting confirmation from support owner.'] + }, + { + key: 'data-fix', + subject: 'Data Correction', + description: 'Track correction of a known metadata inconsistency in a historical record.', + priority: 'high', + currentStatus: 'closed', + statusTimeline: ['open', 'closed'], + comments: ['[Ticket Seed] Root cause isolated.', '[Ticket Seed] Fix applied and verified.'] + }, + { + key: 'security-review', + subject: 'Security Review', + description: 'Coordinate follow-up actions from a routine security review checklist.', + priority: 'critical', + currentStatus: 'open', + statusTimeline: ['open'], + comments: ['[Ticket Seed] Findings captured for remediation planning.', '[Ticket Seed] Engineering owner assigned.'] + }, + { + key: 'duplicate-cleanup', + subject: 'Duplicate Cleanup', + description: 'Resolve duplicate issue reports and consolidate into canonical tracking.', + priority: 'low', + currentStatus: 'closed', + statusTimeline: ['open', 'closed'], + comments: ['[Ticket Seed] Duplicate records merged and references updated.'] + } +]; + +const TICKET_REFERENCE_SCENARIOS: TicketReferenceScenario[] = [ + { + sourceKey: 'security-review', + targetKey: 'ops-check', + relationship: 'blocks' + }, + { + sourceKey: 'duplicate-cleanup', + targetKey: 'data-fix', + relationship: 'duplicates' + }, + { + sourceKey: 'ops-check', + targetKey: 'data-fix', + relationship: 'relates_to' + } +]; + +/** + * Seed ticket domain mock data. + * + * Idempotent: safe to run multiple times. + */ +export async function seed(knex: Knex): Promise { + await knex.raw(` + SET SCHEMA 'biohub'; + SET SEARCH_PATH = 'biohub','public'; + `); + + const createUser = await getSeedCreateUser(knex); + const teamId = await getOrCreateSeedTeam(knex, createUser); + + const seededTicketsByKey: Record = {}; + + for (const scenario of TICKET_SCENARIOS) { + const ticket = await ensureTicket(knex, { + subject: scenario.subject, + description: scenario.description, + priority: scenario.priority, + status: scenario.currentStatus, + teamId, + createUser + }); + + seededTicketsByKey[scenario.key] = ticket; + + await ensureStatusTimeline(knex, ticket.ticket_id, scenario.statusTimeline, createUser); + + for (const commentText of scenario.comments) { + const comment = await ensureComment(knex, commentText, createUser); + await ensureTicketComment(knex, ticket.ticket_id, comment.comment_id, createUser); + } + } + + for (const referenceScenario of TICKET_REFERENCE_SCENARIOS) { + const sourceTicket = seededTicketsByKey[referenceScenario.sourceKey]; + const targetTicket = seededTicketsByKey[referenceScenario.targetKey]; + + if (!sourceTicket || !targetTicket) { + continue; + } + + await ensureTicketReference( + knex, + sourceTicket.ticket_id, + targetTicket.ticket_id, + referenceScenario.relationship, + createUser + ); + } +} + +const getSeedCreateUser = async (knex: Knex): Promise => { + const createUserRow = await knex('system_user').whereNull('record_end_date').select('system_user_id').first(); + return createUserRow?.system_user_id ?? 1; +}; + +const getOrCreateSeedTeam = async (knex: Knex, createUser: number): Promise => { + const existingTeam = await knex('team') + .where('name', 'Seed Ticket Team') + .whereNull('record_end_date') + .select('team_id') + .first(); + + if (existingTeam) { + return existingTeam.team_id; + } + + const [createdTeam] = await knex('team') + .insert({ + name: 'Seed Ticket Team', + description: 'Auto-created team for seeded ticket data.', + create_user: createUser + }) + .returning(['team_id']); + + return createdTeam.team_id; +}; + +const ensureTicket = async ( + knex: Knex, + input: EnsureTicketInput +): Promise<{ ticket_id: string; status: TicketStatus }> => { + const existing = await knex('ticket').where({ subject: input.subject }).whereNull('record_end_date').first(); + + if (existing) { + await knex('ticket').where({ ticket_id: existing.ticket_id }).update({ + description: input.description, + priority: input.priority, + status: input.status, + team_id: input.teamId + }); + + return { ticket_id: existing.ticket_id, status: input.status }; + } + + const [created] = await knex('ticket') + .insert({ + ticket_slug: await generateUniqueTicketSlug(knex), + subject: input.subject, + description: input.description, + team_id: input.teamId, + priority: input.priority, + status: input.status, + create_user: input.createUser + }) + .returning(['ticket_id', 'status']); + + return created; +}; + +/** + * Generate an unused DDDNNNNN ticket slug using the next available + * sequence for the current UTC day. + */ +const generateUniqueTicketSlug = async (knex: Knex): Promise => { + const now = new Date(); + const utcYearStart = Date.UTC(now.getUTCFullYear(), 0, 0); + const utcToday = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()); + const dayOfYear = Math.floor((utcToday - utcYearStart) / (1000 * 60 * 60 * 24)); + const dayPrefix = dayOfYear.toString().padStart(3, '0'); + + const rows = await knex('ticket') + .whereRaw('LEFT(ticket_slug, 3) = ?', [dayPrefix]) + .select<{ ticket_slug: string }[]>('ticket_slug'); + + const usedSlugs = new Set(rows.map((row) => row.ticket_slug)); + + for (let sequence = 0; sequence <= 99999; sequence++) { + const candidate = `${dayPrefix}${sequence.toString().padStart(5, '0')}`; + + if (!usedSlugs.has(candidate)) { + return candidate; + } + } + + throw new Error('Unable to generate a unique ticket_slug for seed data'); +}; + +const ensureStatusTimeline = async ( + knex: Knex, + ticketId: string, + statuses: TicketStatus[], + createUser: number +): Promise => { + for (const status of statuses) { + await ensureTicketStatus(knex, ticketId, status, createUser); + } +}; + +const ensureTicketStatus = async ( + knex: Knex, + ticketId: string, + status: TicketStatus, + createUser: number +): Promise => { + const existing = await knex('ticket_status') + .where({ ticket_id: ticketId, status }) + .whereNull('record_end_date') + .first(); + + if (existing) { + return; + } + + await knex('ticket_status').insert({ + ticket_id: ticketId, + status, + create_user: createUser + }); +}; + +const ensureComment = async (knex: Knex, comment: string, createUser: number): Promise<{ comment_id: string }> => { + const existing = await knex('comment').where({ comment }).first(); + + if (existing) { + return { comment_id: existing.comment_id }; + } + + const [created] = await knex('comment').insert({ comment, create_user: createUser }).returning(['comment_id']); + return created; +}; + +const ensureTicketComment = async ( + knex: Knex, + ticketId: string, + commentId: string, + createUser: number +): Promise => { + const existing = await knex('ticket_comment') + .where({ ticket_id: ticketId, comment_id: commentId }) + .whereNull('record_end_date') + .first(); + + if (existing) { + return; + } + + await knex('ticket_comment').insert({ + ticket_id: ticketId, + comment_id: commentId, + create_user: createUser + }); +}; + +const ensureTicketReference = async ( + knex: Knex, + sourceTicketId: string, + targetTicketId: string, + relationship: TicketRelationshipType, + createUser: number +): Promise => { + const existing = await knex('ticket_reference') + .where({ + source_ticket_id: sourceTicketId, + target_ticket_id: targetTicketId, + relationship + }) + .whereNull('record_end_date') + .first(); + + if (existing) { + return; + } + + await knex('ticket_reference').insert({ + source_ticket_id: sourceTicketId, + target_ticket_id: targetTicketId, + relationship, + create_user: createUser + }); +};