Add repair notes journal with running feed, visibility, and status tagging

New repair_note table for timestamped journal entries on tickets. Each
note captures author, content, visibility (internal or customer-facing),
and the ticket status at time of writing. Notes display as a running
feed on the ticket detail page with newest first. Internal notes have
a lock icon, customer-visible notes highlighted in blue. Supports add
and delete with appropriate permission gating.
This commit is contained in:
Ryan Moon
2026-03-29 10:27:39 -05:00
parent 01cff80f2b
commit 7eac03f6c2
11 changed files with 334 additions and 2 deletions

View File

@@ -0,0 +1,12 @@
CREATE TYPE "repair_note_visibility" AS ENUM ('internal', 'customer');
CREATE TABLE "repair_note" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"repair_ticket_id" uuid NOT NULL REFERENCES "repair_ticket"("id"),
"author_id" uuid NOT NULL REFERENCES "user"("id"),
"author_name" varchar(255) NOT NULL,
"content" text NOT NULL,
"visibility" "repair_note_visibility" NOT NULL DEFAULT 'internal',
"ticket_status" "repair_ticket_status",
"created_at" timestamp with time zone NOT NULL DEFAULT now()
);

View File

@@ -127,6 +127,13 @@
"when": 1774770000000,
"tag": "0017_repair_in_transit_status",
"breakpoints": true
},
{
"idx": 18,
"version": "7",
"when": 1774780000000,
"tag": "0018_repair_notes",
"breakpoints": true
}
]
}

View File

@@ -140,6 +140,26 @@ export const repairLineItems = pgTable('repair_line_item', {
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
})
export const repairNoteVisibilityEnum = pgEnum('repair_note_visibility', ['internal', 'customer'])
export const repairNotes = pgTable('repair_note', {
id: uuid('id').primaryKey().defaultRandom(),
repairTicketId: uuid('repair_ticket_id')
.notNull()
.references(() => repairTickets.id),
authorId: uuid('author_id')
.notNull()
.references(() => users.id),
authorName: varchar('author_name', { length: 255 }).notNull(),
content: text('content').notNull(),
visibility: repairNoteVisibilityEnum('visibility').notNull().default('internal'),
ticketStatus: repairTicketStatusEnum('ticket_status'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
})
export type RepairNote = typeof repairNotes.$inferSelect
export type RepairNoteInsert = typeof repairNotes.$inferInsert
export const repairServiceTemplates = pgTable('repair_service_template', {
id: uuid('id').primaryKey().defaultRandom(),
companyId: uuid('company_id')

View File

@@ -9,10 +9,11 @@ import {
RepairBatchCreateSchema,
RepairBatchUpdateSchema,
RepairBatchStatusUpdateSchema,
RepairNoteCreateSchema,
RepairServiceTemplateCreateSchema,
RepairServiceTemplateUpdateSchema,
} from '@forte/shared/schemas'
import { RepairTicketService, RepairLineItemService, RepairBatchService, RepairServiceTemplateService } from '../../services/repair.service.js'
import { RepairTicketService, RepairLineItemService, RepairBatchService, RepairNoteService, RepairServiceTemplateService } from '../../services/repair.service.js'
export const repairRoutes: FastifyPluginAsync = async (app) => {
// --- Repair Tickets ---
@@ -187,6 +188,39 @@ export const repairRoutes: FastifyPluginAsync = async (app) => {
return reply.send(result)
})
// --- Repair Notes ---
app.post('/repair-tickets/:ticketId/notes', { preHandler: [app.authenticate, app.requirePermission('repairs.edit')] }, async (request, reply) => {
const { ticketId } = request.params as { ticketId: string }
const parsed = RepairNoteCreateSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
}
const ticket = await RepairTicketService.getById(app.db, request.companyId, ticketId)
if (!ticket) return reply.status(404).send({ error: { message: 'Repair ticket not found', statusCode: 404 } })
// Look up author name from users table
const { users } = await import('../../db/schema/users.js')
const { eq } = await import('drizzle-orm')
const [author] = await app.db.select({ firstName: users.firstName, lastName: users.lastName }).from(users).where(eq(users.id, request.user.id)).limit(1)
const authorName = author ? `${author.firstName} ${author.lastName}` : 'Unknown'
const note = await RepairNoteService.create(app.db, ticketId, request.user.id, authorName, ticket.status, parsed.data)
return reply.status(201).send(note)
})
app.get('/repair-tickets/:ticketId/notes', { preHandler: [app.authenticate, app.requirePermission('repairs.view')] }, async (request, reply) => {
const { ticketId } = request.params as { ticketId: string }
const notes = await RepairNoteService.listByTicket(app.db, ticketId)
return reply.send({ data: notes })
})
app.delete('/repair-notes/:id', { preHandler: [app.authenticate, app.requirePermission('repairs.admin')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const note = await RepairNoteService.delete(app.db, id)
if (!note) return reply.status(404).send({ error: { message: 'Note not found', statusCode: 404 } })
return reply.send(note)
})
// --- Repair Service Templates ---
app.post('/repair-service-templates', { preHandler: [app.authenticate, app.requirePermission('repairs.admin')] }, async (request, reply) => {

View File

@@ -5,6 +5,7 @@ import {
repairLineItems,
repairBatches,
repairServiceTemplates,
repairNotes,
} from '../db/schema/repairs.js'
import type {
RepairTicketCreateInput,
@@ -13,6 +14,7 @@ import type {
RepairLineItemUpdateInput,
RepairBatchCreateInput,
RepairBatchUpdateInput,
RepairNoteCreateInput,
RepairServiceTemplateCreateInput,
RepairServiceTemplateUpdateInput,
PaginationInput,
@@ -457,3 +459,36 @@ export const RepairServiceTemplateService = {
return template ?? null
},
}
export const RepairNoteService = {
async create(db: PostgresJsDatabase<any>, ticketId: string, authorId: string, authorName: string, ticketStatus: string, input: RepairNoteCreateInput) {
const [note] = await db
.insert(repairNotes)
.values({
repairTicketId: ticketId,
authorId,
authorName,
content: input.content,
visibility: input.visibility,
ticketStatus: ticketStatus as any,
})
.returning()
return note
},
async listByTicket(db: PostgresJsDatabase<any>, ticketId: string) {
return db
.select()
.from(repairNotes)
.where(eq(repairNotes.repairTicketId, ticketId))
.orderBy(repairNotes.createdAt)
},
async delete(db: PostgresJsDatabase<any>, id: string) {
const [note] = await db
.delete(repairNotes)
.where(eq(repairNotes.id, id))
.returning()
return note ?? null
},
}