Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ bun run setup # One-time setup (deps, Docker, migrations, seed)
bun run dev # Dev server at localhost:3000 (login: demo@example.com / password)
bun run build && bun run db:generate && bun run db:migrate
bun run test && bun run test:e2e && bun run lint && bun run typecheck
bun apps/web/scripts/backfill-ticket-contacts.ts [--dry-run] # One-shot: link existing portal users to contacts and backfill tickets.requesterContactId
```

## Rules
Expand Down
16 changes: 16 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,22 @@ const post = await db.query.posts.findFirst({

- Single workspace, `DATABASE_URL` singleton

### Authorization

Quackback has two independent authorization systems serving different product domains:

| System | Import | Domain |
| ---------- | ---------------------------- | --------------------------------------------------------------- |
| **Policy** | `@/lib/server/policy` | Feedback portal (boards, posts, comments, chat) |
| **Authz** | `@/lib/server/domains/authz` | Ticketing & workspace admin (tickets, teams, inboxes, SLA, CRM) |

**Which one should I use?**

- Adding a board/post/comment/chat feature → use `policy`
- Adding a ticket/inbox/team/contact/SLA feature → use `authz`

The two systems are independent and will be unified in a future iteration.

## Development Guidelines

### Code Style
Expand Down
164 changes: 164 additions & 0 deletions apps/web/e2e/tests/admin/tickets.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import { test, expect } from '@playwright/test'

const uniqueId = Date.now()

test.describe('Admin Tickets', () => {
test.describe.configure({ mode: 'serial' })

let ticketSubject: string

test('can navigate to tickets section', async ({ page }) => {
await page.goto('/admin')
await page.waitForLoadState('networkidle')

// Sidebar should have a Tickets link
const ticketsLink = page.getByRole('link', { name: /tickets/i })
await expect(ticketsLink).toBeVisible({ timeout: 10000 })

await ticketsLink.click()
await page.waitForURL('**/admin/tickets**')
await expect(page).toHaveURL(/\/admin\/tickets/)
})

test('can create a new ticket', async ({ page }) => {
await page.goto('/admin/tickets/new')
await page.waitForLoadState('networkidle')

ticketSubject = `E2E Ticket ${uniqueId}`

// Fill the subject field
const subjectInput = page.locator('#subject')
await expect(subjectInput).toBeVisible({ timeout: 10000 })
await subjectInput.fill(ticketSubject)

// Fill the description (TipTap rich text editor)
const editor = page.locator('.tiptap').first()
await editor.click()
await page.keyboard.type('This is an automated E2E test ticket description.')

// Submit the form
await page.getByRole('button', { name: /create/i }).click()

// Should navigate to the ticket detail page
await page.waitForURL('**/admin/tickets/ticket_**', { timeout: 15000 })
await expect(page).toHaveURL(/\/admin\/tickets\/ticket_/)
})

test('can view ticket detail', async ({ page }) => {
// Navigate to tickets list
await page.goto('/admin/tickets')
await page.waitForLoadState('networkidle')

// Find and click our ticket in the queue
const ticketRow = page.getByText(ticketSubject)
await expect(ticketRow).toBeVisible({ timeout: 10000 })
await ticketRow.click()

// Should navigate to detail page
await page.waitForURL('**/admin/tickets/ticket_**')

// Verify subject is visible in the properties panel
await expect(page.getByText(ticketSubject)).toBeVisible()

// Verify the properties panel renders key sections
await expect(page.getByText('Status')).toBeVisible()
await expect(page.getByText('Priority')).toBeVisible()
})

test('can add a public reply', async ({ page }) => {
// Navigate to tickets list then open our ticket
await page.goto('/admin/tickets')
await page.waitForLoadState('networkidle')
await page.getByText(ticketSubject).click()
await page.waitForURL('**/admin/tickets/ticket_**')

// Find the thread composer (placeholder "Reply to customer…")
const composer = page.locator('.tiptap').last()
await expect(composer).toBeVisible({ timeout: 10000 })
await composer.click()
await page.keyboard.type('This is a public reply from E2E test.')

// Click Post button
await page.getByRole('button', { name: /post/i }).click()

// The reply should appear in the thread timeline
await expect(page.getByText('This is a public reply from E2E test.')).toBeVisible({
timeout: 10000,
})
})

test('can change ticket status', async ({ page }) => {
await page.goto('/admin/tickets')
await page.waitForLoadState('networkidle')
await page.getByText(ticketSubject).click()
await page.waitForURL('**/admin/tickets/ticket_**')

// Find Status section and its picker button
const statusSection = page.locator('text=Status').locator('..')
const statusButton = statusSection.locator('button').first()
await expect(statusButton).toBeVisible({ timeout: 10000 })
await statusButton.click()

// Select "Solved" from the dropdown
const solvedOption = page.getByRole('option', { name: /solved/i }).or(page.getByText('Solved'))
await solvedOption.first().click()

// Wait for the mutation to complete
await page.waitForLoadState('networkidle')

// Verify the status changed (status badge should show "Solved")
await expect(page.getByText('Solved').first()).toBeVisible({ timeout: 10000 })
})

test('can change ticket priority', async ({ page }) => {
await page.goto('/admin/tickets')
await page.waitForLoadState('networkidle')
await page.getByText(ticketSubject).click()
await page.waitForURL('**/admin/tickets/ticket_**')

// Find Priority section and its picker
const prioritySection = page.locator('text=Priority').locator('..')
const priorityButton = prioritySection.locator('button').first()
await expect(priorityButton).toBeVisible({ timeout: 10000 })
await priorityButton.click()

// Select "High" from the dropdown
const highOption = page.getByRole('option', { name: /high/i }).or(page.getByText('high'))
await highOption.first().click()

await page.waitForLoadState('networkidle')

// Verify priority changed
await expect(page.getByText(/high/i).first()).toBeVisible({ timeout: 10000 })
})

test('can edit ticket subject', async ({ page }) => {
await page.goto('/admin/tickets')
await page.waitForLoadState('networkidle')
await page.getByText(ticketSubject).click()
await page.waitForURL('**/admin/tickets/ticket_**')

// Find the Subject section in properties panel and click it to start editing
const subjectSection = page.locator('text=Subject').locator('..')
await subjectSection.click()

// The input should appear
const subjectInput = subjectSection.locator('input')
await expect(subjectInput).toBeVisible({ timeout: 5000 })

// Clear and type new subject
const newSubject = `Updated E2E Ticket ${uniqueId}`
await subjectInput.clear()
await subjectInput.fill(newSubject)
await subjectInput.press('Enter')

// Wait for save
await page.waitForLoadState('networkidle')

// Verify subject updated (it should appear in the header)
await expect(page.getByText(newSubject).first()).toBeVisible({ timeout: 10000 })

// Update for subsequent tests
ticketSubject = newSubject
})
})
Loading