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
Jump to file
Failed to load files.
Loading
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
43 changes: 22 additions & 21 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,27 +61,28 @@
"@tanstack/react-start": "^1.168.25",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.13.26",
"@tiptap/core": "^3.22.3",
"@tiptap/extension-bubble-menu": "^3.22.3",
"@tiptap/extension-code-block-lowlight": "^3.22.3",
"@tiptap/extension-emoji": "3.22.3",
"@tiptap/extension-image": "^3.22.3",
"@tiptap/extension-link": "^3.22.3",
"@tiptap/extension-mention": "3.22.3",
"@tiptap/extension-placeholder": "^3.22.3",
"@tiptap/extension-table": "^3.22.3",
"@tiptap/extension-table-cell": "^3.22.3",
"@tiptap/extension-table-header": "^3.22.3",
"@tiptap/extension-table-row": "^3.22.3",
"@tiptap/extension-task-item": "^3.22.3",
"@tiptap/extension-task-list": "^3.22.3",
"@tiptap/extension-underline": "^3.22.3",
"@tiptap/extension-youtube": "^3.22.3",
"@tiptap/markdown": "^3.22.3",
"@tiptap/pm": "^3.22.3",
"@tiptap/react": "^3.22.3",
"@tiptap/starter-kit": "^3.22.3",
"@tiptap/suggestion": "^3.22.3",
"@tanstack/start": "^1.120.20",
"@tiptap/core": "3.23.4",
"@tiptap/extension-bubble-menu": "3.23.4",
"@tiptap/extension-code-block-lowlight": "3.23.4",
"@tiptap/extension-emoji": "3.23.4",
"@tiptap/extension-image": "3.23.4",
"@tiptap/extension-link": "3.23.4",
"@tiptap/extension-mention": "3.23.4",
"@tiptap/extension-placeholder": "3.23.4",
"@tiptap/extension-table": "3.23.4",
"@tiptap/extension-table-cell": "3.23.4",
"@tiptap/extension-table-header": "3.23.4",
"@tiptap/extension-table-row": "3.23.4",
"@tiptap/extension-task-item": "3.23.4",
"@tiptap/extension-task-list": "3.23.4",
"@tiptap/extension-underline": "3.23.4",
"@tiptap/extension-youtube": "3.23.4",
"@tiptap/markdown": "3.23.4",
"@tiptap/pm": "3.23.4",
"@tiptap/react": "3.23.4",
"@tiptap/starter-kit": "3.23.4",
"@tiptap/suggestion": "3.23.4",
"bcryptjs": "^3.0.3",
"better-auth": "^1.6.16",
"bullmq": "^5.74.1",
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/lib/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,3 +92,7 @@ server/domains/posts/
- **Mutations**: All in `client/mutations/`, named by domain
- **Services**: Max 400 lines, split by responsibility
- **Hooks**: Max 300 lines, queries only (no mutations)
- **Server functions**: Use `GET` only when input is safe to appear in URLs
(public slugs, locale, pagination, resource IDs). Use `POST` for sensitive
or private input such as tokens, secrets, emails, credentials, free-text
searches, and private filter objects.
118 changes: 116 additions & 2 deletions apps/web/src/lib/server/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,11 +176,18 @@ export {
postExternalLinks,
postExternalLinksRelations,
// Schema tables - changelog
changelogCategories,
changelogCategoriesRelations,
changelogEntries,
changelogEntriesRelations,
changelogEntryPosts,
changelogEntryPostsRelations,
changelogProducts,
changelogProductsRelations,
changelogSegmentVisibility,
changelogSegmentVisibilityRelations,
// Schema tables - live chat
type ChangelogVisibilityConfig,
conversations,
conversationsRelations,
chatMessages,
Expand Down Expand Up @@ -224,6 +231,8 @@ export {
segmentsRelations,
userSegments,
userSegmentsRelations,
portalTabSegmentOverrides,
portalTabSegmentOverridesRelations,
type SegmentRules,
type SegmentCondition,
type SegmentRuleOperator,
Expand Down Expand Up @@ -266,16 +275,121 @@ export {
helpCenterArticlesRelations,
helpCenterArticleFeedback,
helpCenterArticleFeedbackRelations,
// Schema tables - ticketing (Phase 1: RBAC + teams + audit)
teams,
teamsRelations,
teamMemberships,
teamMembershipsRelations,
roles,
permissions,
rolePermissions,
principalRoleAssignments,
auditEvents,
// Schema tables - ticketing (Phase 2: organizations & contacts)
organizations,
organizationsRelations,
contacts,
contactsRelations,
contactUserLinks,
contactUserLinksRelations,
// Schema tables - ticketing (Phase 3: ticket core)
ticketStatuses,
DEFAULT_TICKET_STATUSES,
TICKET_STATUS_CATEGORIES,
tickets,
ticketsRelations,
ticketThreads,
ticketThreadsRelations,
ticketAttachments,
ticketAttachmentsRelations,
ticketParticipants,
ticketParticipantsRelations,
ticketShares,
ticketSharesRelations,
ticketActivity,
ticketActivityRelations,
TICKET_PRIORITIES,
TICKET_CHANNELS,
TICKET_VISIBILITY_SCOPES,
TICKET_THREAD_AUDIENCES,
TICKET_PARTICIPANT_ROLES,
TICKET_SHARE_LEVELS,
// Schema tables - ticketing (Phase 4: inboxes, channels, routing)
inboxes,
inboxesRelations,
inboxChannels,
inboxChannelsRelations,
inboxMemberships,
inboxMembershipsRelations,
routingRules,
routingRulesRelations,
INBOX_CHANNEL_KINDS,
INBOX_MEMBERSHIP_ROLES,
// Schema tables - ticketing (Phase 5: SLA + escalations)
businessHours,
businessHoursRelations,
slaPolicies,
slaPoliciesRelations,
slaTargets,
slaTargetsRelations,
ticketSlaClocks,
ticketSlaClocksRelations,
escalationRules,
escalationRulesRelations,
slaEscalationLog,
slaEscalationLogRelations,
SLA_TARGET_KINDS,
SLA_CLOCK_STATES,
SLA_POLICY_SCOPES,
ESCALATION_RECIPIENT_TYPES,
ESCALATION_CHANNELS,
// Schema tables - ticketing (Phase 7: subscriptions + webhook delivery log)
ticketSubscriptions,
ticketSubscriptionsRelations,
webhookDeliveries,
webhookDeliveriesRelations,
// Schema tables - ticket external links & user mappings (GitHub sync)
ticketExternalLinks,
ticketExternalLinksRelations,
ticketThreadExternalLinks,
ticketThreadExternalLinksRelations,
integrationUserMappings,
integrationUserMappingsRelations,
// Schema tables - integration sync log (observability)
integrationSyncLog,
integrationSyncLogRelations,
// Schema tables - push devices
pushDevices,
// Schema tables - widget profiles
widgetApplications,
widgetApplicationsRelations,
widgetEnvironmentProfiles,
widgetEnvironmentProfilesRelations,
// Types/constants
REACTION_EMOJIS,
USE_CASE_TYPES,
} from '@quackback/db'

// Re-export schema types not covered by @quackback/db/types
export type { ServiceMetadata } from '@quackback/db'
export type { IdentityProviderAttributeMapping } from '@quackback/db'
export type {
ServiceMetadata,
AuditSource,
InboxChannelKind,
InboxMembershipRole,
OrgMetadata,
TicketPriority,
TicketVisibilityScope,
TicketStatusCategory,
WidgetProfileChangelogMode,
WidgetProfileConfigOverrides,
WidgetProfileContentFilters,
WidgetProfileSupportCategory,
WidgetProfileSupportConfig,
WidgetProfileSupportDisplayRules,
WidgetProfileTicketListScope,
WidgetProfileTicketPriority,
IdentityProviderAttributeMapping
} from '@quackback/db'

// Re-export types (for client components that need types without side effects)
export * from '@quackback/db/types'
6 changes: 4 additions & 2 deletions apps/web/src/lib/server/markdown-tiptap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import { sanitizeTiptapContent } from '@/lib/server/sanitize-tiptap'
* CodeBlockLowlight (lowlight needs no special markdown handling; StarterKit's
* codeBlock handles ``` fences).
*/

const SERVER_EXTENSIONS = [
StarterKit.configure({
heading: { levels: [1, 2, 3] },
Expand All @@ -43,7 +44,7 @@ const SERVER_EXTENSIONS = [
TableRow,
TableCell,
TableHeader,
]
] as any[]

/** Singleton MarkdownManager - created once at module load */
const manager = new MarkdownManager({
Expand Down Expand Up @@ -76,6 +77,7 @@ export function tiptapJsonToMarkdown(json: TiptapContent | JSONContent): string
* Slim extension set for comments — no images, no tables, no YouTube.
* Comments are short, dense, and inline; we want the safe subset only.
*/

const COMMENT_EXTENSIONS = [
StarterKit.configure({
heading: { levels: [1, 2, 3] },
Expand All @@ -85,7 +87,7 @@ const COMMENT_EXTENSIONS = [
Underline,
TaskList,
TaskItem.configure({ nested: true }),
]
] as any[]

const commentManager = new MarkdownManager({
extensions: COMMENT_EXTENSIONS,
Expand Down
111 changes: 111 additions & 0 deletions apps/web/src/lib/server/public-url.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { getRequestHeaders } from '@tanstack/react-start/server'
import { getBaseUrl } from '@/lib/server/config'
import { getPublicOriginFromHeaders } from '@/lib/server/integrations/oauth'

const LOCAL_HOSTNAMES = new Set(['localhost', 'host.docker.internal', '0.0.0.0', '::1'])

function isLocalOrPrivateHostname(hostname: string): boolean {
const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, '')

if (
LOCAL_HOSTNAMES.has(normalized) ||
normalized.endsWith('.localhost') ||
normalized.endsWith('.local')
) {
return true
}

if (normalized.includes(':')) {
return (
normalized === '::1' ||
normalized.startsWith('fc') ||
normalized.startsWith('fd') ||
normalized.startsWith('fe80:')
)
}

const match = normalized.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/)
if (!match) return false

const octets = match.slice(1).map(Number)
if (octets.some((n) => n > 255)) return false

const [a, b] = octets
return (
a === 0 ||
a === 10 ||
a === 127 ||
(a === 169 && b === 254) ||
(a === 172 && b >= 16 && b <= 31) ||
(a === 192 && b === 168) ||
(a === 100 && b >= 64 && b <= 127)
)
}

function isLocalOrPrivateUrl(value: string): boolean {
try {
return isLocalOrPrivateHostname(new URL(value).hostname)
} catch {
return true
}
}

function isUsableExternalOrigin(origin: string): boolean {
try {
const url = new URL(origin)
return url.protocol === 'https:' && !isLocalOrPrivateHostname(url.hostname)
} catch {
return false
}
}

function trimTrailingSlash(value: string): string {
return value.replace(/\/+$/, '')
}

export function getActiveRequestHeaders(): Headers | undefined {
try {
return getRequestHeaders()
} catch {
return undefined
}
}

export function resolvePublicBaseUrl(
requestHeaders: Headers | undefined = getActiveRequestHeaders()
): string {
const configuredBaseUrl = trimTrailingSlash(getBaseUrl())

if (!isLocalOrPrivateUrl(configuredBaseUrl)) {
return configuredBaseUrl
}

try {
const requestOrigin = requestHeaders ? getPublicOriginFromHeaders(requestHeaders) : ''
if (requestOrigin && isUsableExternalOrigin(requestOrigin)) {
return requestOrigin
}
} catch {
// Fall back to the configured URL below.
}

return configuredBaseUrl
}

export function rewriteUrlToPublicBaseUrl(
value: string,
requestHeaders: Headers | undefined = getActiveRequestHeaders()
): string {
const publicBaseUrl = resolvePublicBaseUrl(requestHeaders)

try {
const url = new URL(value)
const baseUrl = new URL(publicBaseUrl)
url.protocol = baseUrl.protocol
url.hostname = baseUrl.hostname
url.port = baseUrl.port
return url.toString()
} catch {
return value
}
}
Loading