Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
fb80499
first draft of the CRM
melihsunbul May 20, 2026
090b07a
codeql fix
melihsunbul May 20, 2026
cd1d13d
Merge remote-tracking branch 'upstream/main' into authz-and-ticketing
melihsunbul May 20, 2026
f60fd63
refactor(db): consolidate 12 ticketing migrations into single 0066_ti…
melihsunbul May 20, 2026
1b6d2aa
fix(db): handle scopes column type conflict with main's 0050 migration
melihsunbul May 20, 2026
8049093
fix(db): remove duplicate integration_sync_log CREATE TABLE in migration
melihsunbul May 21, 2026
7c028be
Merge remote-tracking branch 'upstream/main' into authz-and-ticketing
melihsunbul Jun 3, 2026
a78b43f
merge: resolve upstream/main into authz-and-ticketing
melihsunbul Jun 11, 2026
5a75ae7
fix(widget): resolve syntax error in routes/widget/index.tsx after merge
melihsunbul Jun 11, 2026
3bc1e1c
fix: resolve remaining merge duplicates from upstream merge
melihsunbul Jun 11, 2026
78c2fd3
fix: resolve remaining merge parse errors causing 500
melihsunbul Jun 11, 2026
e507f4b
fix: resolve merge type errors across ticketing domain layer
melihsunbul Jun 11, 2026
c01cb15
fix(phase-0+1): resolve remaining type errors and document TypeID pre…
melihsunbul Jun 11, 2026
515db82
fix(phase-3): block internal/shared_team threads from external delivery
melihsunbul Jun 11, 2026
af2f899
docs(phase-2): document auth system boundary between policy/ and authz/
melihsunbul Jun 11, 2026
2717839
docs(phase-4): complete OpenAPI spec for all 20 ticket endpoints
melihsunbul Jun 11, 2026
59d75de
test(phase-5): add E2E tests for admin ticket lifecycle
melihsunbul Jun 11, 2026
fd00e5e
test(phase-6): strengthen widget ticket integration tests
melihsunbul Jun 11, 2026
31510d7
test(phase-7): add GitHub ticket sync unit tests
melihsunbul Jun 11, 2026
3249edc
feat: sync ticket threads with GitHub
melihsunbul Jun 12, 2026
1971ca8
Merge remote-tracking branch 'upstream/main' into authz-and-ticketing
melihsunbul Jun 12, 2026
d9f59b6
feat(admin): add customer workspace and harden ticket flows
melihsunbul Jun 14, 2026
2ddb108
Merge remote-tracking branch 'upstream/main' into authz-and-ticketing
melihsunbul Jun 14, 2026
e476858
feat: add scoped widget profiles
melihsunbul Jun 15, 2026
ad4c19d
Merge remote-tracking branch 'upstream/main' into authz-and-ticketing
melihsunbul Jun 15, 2026
5f217c2
fix(widget): harden embed bootstrap and disabled state
melihsunbul Jun 15, 2026
f0aa1f7
Merge remote-tracking branch 'upstream/main' into authz-and-ticketing
melihsunbul Jun 15, 2026
76b7839
feat: implement durable ticket email notifications and fix widget fea…
melihsunbul Jun 16, 2026
62c5c34
Merge remote-tracking branch 'upstream/main' into authz-and-ticketing
melihsunbul Jun 16, 2026
f4a7a89
fix github ticket sync event mappings
melihsunbul Jun 16, 2026
1b969c5
feat: improve auth/public-url handling and ticket notification scope
melihsunbul Jun 17, 2026
473cd58
Merge remote-tracking branch 'upstream/main' into authz-and-ticketing
melihsunbul Jun 17, 2026
d52a125
feat: portal tab customization
melihsunbul Jun 17, 2026
405f538
feat: add audience & visibility controls for help center categories a…
melihsunbul Jun 17, 2026
54d418d
feat(tickets): add end-to-end attachments and GitHub sync hardening
melihsunbul Jun 17, 2026
b6ab8b6
chore: fix pre-commit blockers and clean up ticket attachment changes
melihsunbul Jun 17, 2026
4abe649
feat: expand authz surfaces, MCP tooling, and API coverage
melihsunbul Jun 18, 2026
4999996
feat: add RBAC, teams, conversation actions, and expand API surface
melihsunbul Jun 18, 2026
fbf0ed0
feat(api,mcp): add OpenAPI schemas for chat tags, conversation action…
melihsunbul Jun 18, 2026
4b004de
feat(api): expand OpenAPI schemas and add route coverage test
melihsunbul Jun 18, 2026
cc5be0f
Merge upstream/main into authz-and-ticketing
melihsunbul Jun 19, 2026
14ba128
test: add broad unit coverage and supporting test infrastructure
melihsunbul Jun 22, 2026
00af1b0
feat(db,ids): schema, TypeIDs and migrations for ticketing, RBAC & SLA
melihsunbul Jun 22, 2026
26be433
feat(authz): role-based access control, teams and org/auth surfaces
melihsunbul Jun 22, 2026
2732c5c
feat(events): event dispatch backbone, audit log, webhooks & notifica…
melihsunbul Jun 22, 2026
7981f0c
Merge pull request #33 from ExcellenceCloudGmbH/authz-and-ticketing
melihsunbul Jun 23, 2026
585eef7
Merge branch 'main' into 03-events-audit-webhooks
melihsunbul Jun 25, 2026
4a09bfe
Merge branch 'main' into 03-events-audit-webhooks
melihsunbul Jun 28, 2026
66dd440
Merge remote-tracking branch 'upstream/main'
melihsunbul Jul 2, 2026
c0a2bd9
Merge branch 'main' into 03-events-audit-webhooks
melihsunbul Jul 2, 2026
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
})
})
5 changes: 4 additions & 1 deletion apps/web/e2e/utils/access-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,10 @@ export function flushMagicLinkRateLimit(): void {
['exec', 'quackback-dragonfly', 'redis-cli', '--scan', '--pattern', 'signin:magiclink:*'],
{ encoding: 'utf-8' }
)
const keys = scan.split('\n').map((k) => k.trim()).filter(Boolean)
const keys = scan
.split('\n')
.map((k) => k.trim())
.filter(Boolean)
for (const key of keys) {
execFileSync('docker', ['exec', 'quackback-dragonfly', 'redis-cli', 'del', key], {
stdio: 'pipe',
Expand Down
37 changes: 37 additions & 0 deletions apps/web/e2e/utils/db-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,43 @@ export function getMagicLinkToken(email: string): string {
}
}

/**
* Get the most recent live email OTP code for an email. Better-auth stores
* email OTP rows as `sign-in-otp-<email>` with value `<code>:<attempts>`.
*/
export function getOtpCode(email: string, _host?: string): string {
const query = `
SELECT value
FROM verification
WHERE identifier = 'sign-in-otp-${email.replace(/'/g, "''")}'
AND expires_at > NOW()
ORDER BY created_at DESC
LIMIT 1
`

try {
const result = execSync(
`dotenv -e ../../.env -- bun -e ${JSON.stringify(`
import postgres from 'postgres'
const sql = postgres(process.env.DATABASE_URL)
const rows = await sql.unsafe(${JSON.stringify(query)})
if (rows.length === 0) throw new Error('No live OTP verification row found')
console.log(String(rows[0].value).split(':')[0])
await sql.end()
`)}`,
{
encoding: 'utf-8',
cwd: resolve(__dirname, '../..'), // apps/web directory
}
)

return result.trim()
} catch (error) {
const err = error as { stderr?: string; message: string }
throw new Error(`Failed to get OTP code: ${err.stderr || err.message}`, { cause: error })
}
}

/**
* Ensure a test user has the required role for E2E testing
*
Expand Down
Loading