├── .gitmodules
├── backend
├── services
│ ├── tasks
│ │ ├── tasks.go
│ │ └── mail.go
│ ├── error.go
│ └── auth.go
├── static
│ └── templates
│ │ └── mails
│ │ ├── text
│ │ └── kyc.txt
│ │ └── kyc.html
├── utils
│ ├── time.go
│ ├── commons.go
│ └── html.go
├── integrations
│ ├── error.go
│ └── email
│ │ └── email.go
├── adapters
│ ├── repository
│ │ ├── error.go
│ │ ├── db.go
│ │ └── member.go
│ ├── handler
│ │ ├── params.go
│ │ ├── auth.go
│ │ └── middleware.go
│ ├── xhandler
│ │ ├── middleware.go
│ │ ├── auth.go
│ │ └── handler.go
│ └── store
│ │ └── s3store.go
├── .vscode
│ └── settings.json
├── infra
│ └── srv
│ │ ├── scripts
│ │ ├── before.sh
│ │ ├── after.sh
│ │ ├── validate.sh
│ │ ├── stop.sh
│ │ └── start.sh
│ │ └── appspec.yml
├── srv.DockerFile
├── xsrv.DockerFile
├── models
│ ├── member.go
│ ├── settings.go
│ └── customer.go
├── go.mod
├── cmd
│ └── xsrv
│ │ └── main.go
└── config.go
├── frontend
├── .prettierrc
├── vercel.json
├── postcss.config.js
├── .env.example
├── src
│ ├── vite-env.d.ts
│ ├── lib
│ │ ├── utils.ts
│ │ └── search-params.ts
│ ├── routes
│ │ ├── (auth)
│ │ │ ├── recover.tsx
│ │ │ └── signout.tsx
│ │ ├── _account
│ │ │ ├── workspaces
│ │ │ │ ├── $workspaceId
│ │ │ │ │ ├── setup
│ │ │ │ │ │ └── index.tsx
│ │ │ │ │ ├── _workspace
│ │ │ │ │ │ ├── threads
│ │ │ │ │ │ │ ├── spam.tsx
│ │ │ │ │ │ │ ├── route.tsx
│ │ │ │ │ │ │ ├── done.tsx
│ │ │ │ │ │ │ └── labels.$labelId.tsx
│ │ │ │ │ │ ├── index.tsx
│ │ │ │ │ │ ├── search.tsx
│ │ │ │ │ │ └── route.tsx
│ │ │ │ │ └── settings
│ │ │ │ │ │ ├── chat.tsx
│ │ │ │ │ │ ├── slack.tsx
│ │ │ │ │ │ ├── billing.tsx
│ │ │ │ │ │ ├── events.tsx
│ │ │ │ │ │ ├── github.tsx
│ │ │ │ │ │ ├── linear.tsx
│ │ │ │ │ │ ├── webhooks.tsx
│ │ │ │ │ │ ├── notifications.tsx
│ │ │ │ │ │ ├── route.tsx
│ │ │ │ │ │ └── ai.tsx
│ │ │ │ └── $workspaceId.tsx
│ │ │ └── route.tsx
│ │ ├── index.tsx
│ │ └── __root.tsx
│ ├── hooks
│ │ └── theme.ts
│ ├── components
│ │ ├── ui
│ │ │ ├── skeleton.tsx
│ │ │ ├── collapsible.tsx
│ │ │ ├── label.tsx
│ │ │ ├── textarea.tsx
│ │ │ ├── separator.tsx
│ │ │ ├── input.tsx
│ │ │ ├── toaster.tsx
│ │ │ ├── checkbox.tsx
│ │ │ ├── switch.tsx
│ │ │ ├── badge.tsx
│ │ │ ├── tooltip.tsx
│ │ │ ├── popover.tsx
│ │ │ ├── radio-group.tsx
│ │ │ ├── avatar.tsx
│ │ │ ├── scroll-area.tsx
│ │ │ ├── alert.tsx
│ │ │ ├── resizable.tsx
│ │ │ ├── button.tsx
│ │ │ ├── tabs.tsx
│ │ │ ├── card.tsx
│ │ │ └── dialog.tsx
│ │ ├── workspace
│ │ │ ├── thread-list.tsx
│ │ │ ├── thread
│ │ │ │ ├── sidepanel-thread-list.tsx
│ │ │ │ ├── message-form.tsx
│ │ │ │ └── threads.tsx
│ │ │ ├── sorts.tsx
│ │ │ ├── sidenav-mobile-links.tsx
│ │ │ ├── settings
│ │ │ │ ├── sidenav-mobile-links.tsx
│ │ │ │ └── email
│ │ │ │ │ └── dns.tsx
│ │ │ ├── header.tsx
│ │ │ ├── insights
│ │ │ │ └── overview.tsx
│ │ │ └── thread-list-item.tsx
│ │ ├── spinner.tsx
│ │ ├── notfound.tsx
│ │ ├── theme-toggler.tsx
│ │ └── thread
│ │ │ └── customer-events.tsx
│ ├── db
│ │ ├── constants.ts
│ │ └── helpers.ts
│ ├── main.tsx
│ ├── providers.tsx
│ └── assets
│ │ └── react.svg
├── tsconfig.node.json
├── components.json
├── index.html
├── .vscode
│ └── settings.json
├── .eslintrc.cjs
├── tsconfig.json
├── vite.config.ts
├── README.md
├── public
│ └── vite.svg
├── tailwind.config.ts
└── package.json
├── .dockerignore
├── zyg.code-workspace
├── .vscode
└── settings.json
├── .github
└── workflows
│ └── audit.yml
├── templates
├── package.json
└── emails
│ └── kyc.tsx
├── .env.example
├── README.md
├── .gitignore
└── docker-compose.yml
/.gitmodules:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/backend/services/tasks/tasks.go:
--------------------------------------------------------------------------------
1 | package tasks
2 |
--------------------------------------------------------------------------------
/frontend/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "plugins": ["prettier-plugin-tailwindcss"]
3 | }
4 |
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | .git
2 | .github
3 | .env.example
4 | .gitignore
5 |
6 | node_modules
7 | dist
8 |
--------------------------------------------------------------------------------
/frontend/vercel.json:
--------------------------------------------------------------------------------
1 | {
2 | "rewrites": [{ "source": "/(.*)", "destination": "/" }]
3 | }
4 |
--------------------------------------------------------------------------------
/frontend/postcss.config.js:
--------------------------------------------------------------------------------
1 | export default {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/frontend/.env.example:
--------------------------------------------------------------------------------
1 | VITE_ZYG_URL=
2 | VITE_SUPABASE_URL=
3 | VITE_SUPABASE_ANON_KEY=
4 | VITE_SENTRY_ENV=
5 | VITE_SENTRY_ENABLED=1
6 | VITE_SENTRY_TELEMETRY_ENABLED=1
--------------------------------------------------------------------------------
/frontend/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
3 | interface ImportMetaEnv {
4 | readonly VITE_SENTRY_ENABLED: string;
5 | readonly VITE_SENTRY_ENV: string;
6 | }
7 |
--------------------------------------------------------------------------------
/backend/static/templates/mails/text/kyc.txt:
--------------------------------------------------------------------------------
1 | You started a conversation.
2 |
3 | {{ .PreviewText }}
4 |
5 | Verify your email {{ .MagicLink }}
6 |
7 | ❤️ Zyg ・ Open source, made with love around the world ❤️
--------------------------------------------------------------------------------
/frontend/src/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { type ClassValue, clsx } from "clsx";
2 | import { twMerge } from "tailwind-merge";
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs));
6 | }
7 |
--------------------------------------------------------------------------------
/frontend/src/routes/(auth)/recover.tsx:
--------------------------------------------------------------------------------
1 | import { createFileRoute } from "@tanstack/react-router";
2 |
3 | export const Route = createFileRoute("/(auth)/recover")({
4 | component: () =>
Hello /recover!
,
5 | });
6 |
--------------------------------------------------------------------------------
/backend/utils/time.go:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | import "time"
4 |
5 | func FromRFC3339OrNow(ts string) time.Time {
6 | t, err := time.Parse(time.RFC3339, ts)
7 | if err != nil {
8 | now := time.Now().UTC()
9 | return now
10 | }
11 | return t
12 | }
13 |
--------------------------------------------------------------------------------
/backend/integrations/error.go:
--------------------------------------------------------------------------------
1 | package integrations
2 |
3 | type integrationErr string
4 |
5 | func (err integrationErr) Error() string {
6 | return string(err)
7 | }
8 |
9 | const (
10 | ErrPostmarkSendMail = integrationErr("postmark send mail error")
11 | )
12 |
--------------------------------------------------------------------------------
/frontend/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "skipLibCheck": true,
5 | "module": "ESNext",
6 | "moduleResolution": "bundler",
7 | "allowSyntheticDefaultImports": true,
8 | "strict": true
9 | },
10 | "include": ["vite.config.ts"]
11 | }
12 |
--------------------------------------------------------------------------------
/backend/adapters/repository/error.go:
--------------------------------------------------------------------------------
1 | package repository
2 |
3 | type dbErr string
4 |
5 | func (err dbErr) Error() string {
6 | return string(err)
7 | }
8 |
9 | const (
10 | ErrEmpty = dbErr("got nothing")
11 | ErrQuery = dbErr("db query failed")
12 | ErrTxQuery = dbErr("db tx query failed")
13 | )
14 |
--------------------------------------------------------------------------------
/backend/adapters/handler/params.go:
--------------------------------------------------------------------------------
1 | package handler
2 |
3 | //var StatusParams = map[string]bool{
4 | // "todo": true,
5 | // "snoozed": true,
6 | // "done": true,
7 | // "unsnoozed": true,
8 | //}
9 | //
10 | //var ReasonParams = map[string]bool{
11 | // "replied": true,
12 | // "unreplied": true,
13 | //}
14 |
--------------------------------------------------------------------------------
/backend/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "editor.fontFamily": "'Cascadia Mono', 'SauceCodePro Nerd Font', Consolas, monospace",
3 | "cSpell.words": [
4 | "addedby",
5 | "inbc",
6 | "oubm",
7 | "segmentio",
8 | "sess",
9 | "templ",
10 | "xhandler"
11 | ],
12 | "cSpell.ignoreWords": [
13 | "mpim"
14 | ]
15 | }
16 |
--------------------------------------------------------------------------------
/frontend/src/hooks/theme.ts:
--------------------------------------------------------------------------------
1 | import { ThemeProviderContext } from "@/providers";
2 | import React from "react";
3 |
4 | export const useTheme = () => {
5 | const context = React.useContext(ThemeProviderContext);
6 |
7 | if (context === undefined)
8 | throw new Error("useTheme must be used within a ThemeProvider");
9 |
10 | return context;
11 | };
12 |
--------------------------------------------------------------------------------
/frontend/src/components/ui/skeleton.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "@/lib/utils"
2 |
3 | function Skeleton({
4 | className,
5 | ...props
6 | }: React.HTMLAttributes) {
7 | return (
8 |
12 | )
13 | }
14 |
15 | export { Skeleton }
16 |
--------------------------------------------------------------------------------
/frontend/src/routes/_account/workspaces/$workspaceId/setup/index.tsx:
--------------------------------------------------------------------------------
1 | import { createFileRoute } from "@tanstack/react-router";
2 |
3 | export const Route = createFileRoute("/_account/workspaces/$workspaceId/setup/")({
4 | component: WorkspaceSetup,
5 | });
6 |
7 | function WorkspaceSetup() {
8 | return ...
;
9 | }
10 |
--------------------------------------------------------------------------------
/frontend/src/components/ui/collapsible.tsx:
--------------------------------------------------------------------------------
1 | import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
2 |
3 | const Collapsible = CollapsiblePrimitive.Root
4 |
5 | const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
6 |
7 | const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
8 |
9 | export { Collapsible, CollapsibleTrigger, CollapsibleContent }
10 |
--------------------------------------------------------------------------------
/frontend/src/routes/_account/workspaces/$workspaceId/_workspace/threads/spam.tsx:
--------------------------------------------------------------------------------
1 | import { createFileRoute } from "@tanstack/react-router";
2 |
3 | export const Route = createFileRoute(
4 | "/_account/workspaces/$workspaceId/_workspace/threads/spam"
5 | )({
6 | component: () => (
7 | Hello /_account/workspaces/$workspaceId/_workspace/threads/spam!
8 | ),
9 | });
10 |
--------------------------------------------------------------------------------
/backend/utils/commons.go:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | import "encoding/json"
4 |
5 | func StructToMap(obj interface{}) (map[string]interface{}, error) {
6 | data, err := json.Marshal(obj)
7 | if err != nil {
8 | return nil, err
9 | }
10 |
11 | var mapData map[string]interface{}
12 | err = json.Unmarshal(data, &mapData)
13 | if err != nil {
14 | return nil, err
15 | }
16 |
17 | return mapData, nil
18 | }
19 |
--------------------------------------------------------------------------------
/frontend/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "new-york",
4 | "rsc": false,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "tailwind.config.js",
8 | "css": "src/globals.css",
9 | "baseColor": "neutral",
10 | "cssVariables": true,
11 | "prefix": ""
12 | },
13 | "aliases": {
14 | "components": "@/components",
15 | "utils": "@/lib/utils"
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/frontend/src/routes/_account/workspaces/$workspaceId/_workspace/index.tsx:
--------------------------------------------------------------------------------
1 | import { createFileRoute, redirect } from "@tanstack/react-router";
2 |
3 | export const Route = createFileRoute(
4 | "/_account/workspaces/$workspaceId/_workspace/",
5 | )({
6 | component: () => null,
7 | loader: ({ params }) => {
8 | throw redirect({
9 | params,
10 | to: "/workspaces/$workspaceId/threads/todo",
11 | });
12 | },
13 | });
14 |
--------------------------------------------------------------------------------
/zyg.code-workspace:
--------------------------------------------------------------------------------
1 | {
2 | "folders": [
3 | {
4 | "path": "ai",
5 | "name": "ai"
6 | },
7 | {
8 | "path": "backend",
9 | "name": "backend"
10 | },
11 | {
12 | "path": "frontend",
13 | "name": "frontend"
14 | },
15 | {
16 | "path": ".",
17 | "name": "zyg"
18 | },
19 | {
20 | "path": "templates",
21 | "name": "templates"
22 | }
23 | ]
24 | }
25 |
--------------------------------------------------------------------------------
/frontend/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | Vite + React + TS
9 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
--------------------------------------------------------------------------------
/frontend/src/routes/index.tsx:
--------------------------------------------------------------------------------
1 | import { createFileRoute, redirect } from "@tanstack/react-router";
2 |
3 | // TODO: redirect to last used workspace.
4 | export const Route = createFileRoute("/")({
5 | beforeLoad: async ({ context }) => {
6 | const { supabaseClient } = context;
7 | const { data, error } = await supabaseClient.auth.getSession();
8 | const isAuthenticated = !error && data?.session;
9 | if (!isAuthenticated) {
10 | throw redirect({ to: "/signin" });
11 | }
12 | },
13 | component: () => Index Root at /
,
14 | });
15 |
--------------------------------------------------------------------------------
/frontend/src/components/workspace/thread-list.tsx:
--------------------------------------------------------------------------------
1 | import { ThreadLinkItem } from "@/components/workspace/thread-list-item";
2 | import { Thread } from "@/db/models";
3 | import { Virtuoso } from "react-virtuoso";
4 |
5 | export function ThreadList({
6 | threads,
7 | workspaceId,
8 | }: {
9 | threads: Thread[];
10 | workspaceId: string;
11 | }) {
12 | return (
13 | (
15 |
16 | )}
17 | totalCount={threads.length}
18 | useWindowScroll
19 | />
20 | );
21 | }
22 |
--------------------------------------------------------------------------------
/frontend/src/db/constants.ts:
--------------------------------------------------------------------------------
1 | // Top level thread status.
2 | export const STATUS_TODO = "todo";
3 | // export const STATUS_DONE = "done";
4 |
5 | // in TODO workflow stages.
6 | export const NEEDS_NEXT_RESPONSE = "needs_next_response";
7 | export const NEEDS_FIRST_RESPONSE = "needs_first_response";
8 | export const HOLD = "hold";
9 | export const WAITING_ON_CUSTOMER = "waiting_on_customer";
10 |
11 | // in DONE workflow stages.
12 | export const RESOLVED = "resolved";
13 |
14 | // in other workflow, reserved for later usage.
15 | // for other status+stages, haven't decided yet.
16 | export const SPAM = "spam";
17 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "files.exclude": {
3 | "**/.git": true,
4 | "**/.svn": true,
5 | "**/.hg": true,
6 | "**/CVS": true,
7 | "**/.DS_Store": true,
8 | "**/Thumbs.db": true,
9 | "**/node_modules": true,
10 | "**/dist": true,
11 | "**/.next": true,
12 | "**/out": true,
13 | "**/yarn.lock": true,
14 | "**/package-lock.json": true,
15 | "**/coverage": true,
16 | "deprecated": true,
17 | "ai": false,
18 | "frontend": false,
19 | "widget": false,
20 | "backend": false
21 | },
22 | "cSpell.words": [
23 | "appspec",
24 | "XSRV"
25 | ]
26 | }
27 |
--------------------------------------------------------------------------------
/backend/infra/srv/scripts/before.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | set -e
3 |
4 | # Create necessary directories if they don't exist
5 | sudo mkdir -p /usr/local/bin
6 |
7 | # Set ownership to ubuntu user
8 | sudo chown ubuntu:ubuntu /usr/local/bin/app || true
9 |
10 | # Set proper permissions
11 | sudo chmod 755 /usr/local/bin/app || true
12 |
13 | # Ensure systemd directory exists and has correct permissions
14 | sudo mkdir -p /etc/systemd/system
15 | sudo chmod 755 /etc/systemd/system
16 |
17 | # Set ownership to ubuntu user
18 | sudo chown ubuntu:ubuntu /etc/systemd/system/srv.service || true
19 |
20 | # Stop the service if it's running (ignore if it fails)
21 | sudo systemctl stop srv.service || true
22 |
--------------------------------------------------------------------------------
/frontend/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "editor.fontFamily": "'Cascadia Mono', 'SauceCodePro Nerd Font', Consolas, monospace",
3 | "editor.formatOnSave": true,
4 | "files.associations": {
5 | "*.css": "tailwindcss"
6 | },
7 | "tailwindCSS.includeLanguages": {
8 | "plaintext": "html"
9 | },
10 | "tailwindCSS.colorDecorators": true,
11 | "editor.quickSuggestions": {
12 | "strings": "on"
13 | },
14 | "files.autoSave": "afterDelay",
15 | "files.autoSaveDelay": 1000,
16 | "cSpell.enableFiletypes": ["typescriptreact"],
17 | "cSpell.words": ["hookform", "overscan", "sidenav", "signin", "signout"],
18 | "editor.codeActionsOnSave": {
19 | "source.fixAll": "explicit"
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/backend/infra/srv/scripts/after.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | set -e
3 |
4 | # Log all commands for debugging
5 | exec 1> >(logger -s -t $(basename $0)) 2>&1
6 |
7 | echo "Starting after-install script execution..."
8 |
9 | # Reload systemd to recognize new or modified service file
10 | echo "Reloading systemd daemon..."
11 | sudo systemctl daemon-reload
12 |
13 | # Enable the service to start on boot
14 | echo "Enabling srv service..."
15 | sudo systemctl enable srv.service
16 |
17 | # Verify application binary exists and is executable
18 | if [ ! -x "/usr/local/bin/app" ]; then
19 | echo "ERROR: Application binary is missing or not executable"
20 | exit 1
21 | fi
22 |
23 | echo "After-install script completed successfully"
24 | exit 0
25 |
--------------------------------------------------------------------------------
/.github/workflows/audit.yml:
--------------------------------------------------------------------------------
1 | name: Audit
2 |
3 | on:
4 | workflow_dispatch: {}
5 | push:
6 | branches: [main]
7 | paths:
8 | - 'backend/**'
9 | pull_request:
10 | branches: [main]
11 | paths:
12 | - 'backend/**'
13 |
14 | jobs:
15 | audit:
16 | name: Audit
17 | runs-on: ubuntu-latest
18 | defaults:
19 | run:
20 | working-directory: ./backend
21 | steps:
22 | - uses: actions/checkout@v4
23 |
24 | - name: Set up Go
25 | uses: actions/setup-go@v5
26 | with:
27 | go-version: "1.23"
28 | check-latest: true
29 | cache: true
30 |
31 | - name: Verify
32 | run: go mod verify
33 |
34 | - name: Vet
35 | run: go vet ./...
36 |
37 |
--------------------------------------------------------------------------------
/backend/infra/srv/scripts/validate.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | set -e
3 |
4 | SERVICE_NAME="srv.service"
5 |
6 | # Function to check if service is running
7 | check_service() {
8 | local service_name=$1
9 |
10 | echo "Checking if service $service_name is running..."
11 | if ! systemctl is-active --quiet "$service_name"; then
12 | echo "ERROR: Service $service_name is not running"
13 | systemctl status "$service_name"
14 | return 1
15 | fi
16 | echo "Service $service_name is running"
17 | return 0
18 | }
19 |
20 | main() {
21 | local service=${3:-$SERVICE_NAME}
22 |
23 | check_service "$service" || exit 1
24 |
25 | echo "All validations passed successfully"
26 | exit 0
27 | }
28 |
29 | # Run main with command line arguments
30 | main "$@"
31 |
--------------------------------------------------------------------------------
/templates/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "templates",
3 | "version": "1.0.0",
4 | "description": "",
5 | "main": "index.js",
6 | "scripts": {
7 | "dev": "email dev",
8 | "export:html": "email export --pretty --outDir ../backend/static/templates/mails",
9 | "export:text": "email export --pretty --plainText --outDir ../backend/static/templates/mails/text"
10 | },
11 | "keywords": [],
12 | "author": "",
13 | "license": "ISC",
14 | "devDependencies": {
15 | "@types/react": "^18.3.11",
16 | "@types/react-dom": "^18.3.0",
17 | "react-email": "3.0.1"
18 | },
19 | "dependencies": {
20 | "@react-email/components": "0.0.25",
21 | "@react-email/render": "1.0.1",
22 | "react": "^18.3.1",
23 | "react-dom": "^18.3.1"
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/frontend/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | root: true,
3 | env: { browser: true, es2020: true },
4 | extends: [
5 | "eslint:recommended",
6 | "plugin:@typescript-eslint/recommended",
7 | "plugin:react-hooks/recommended",
8 | "plugin:perfectionist/recommended-natural-legacy",
9 | ],
10 | ignorePatterns: [
11 | "dist",
12 | ".eslintrc.cjs",
13 | "**/components/ui/**",
14 | "postcss.config.js",
15 | "tailwind.config.js",
16 | ],
17 | parser: "@typescript-eslint/parser",
18 | plugins: ["react-refresh", "perfectionist"],
19 | rules: {
20 | // "react-refresh/only-export-components": [
21 | // "warn",
22 | // { allowConstantExport: true },
23 | // ],
24 | "@typescript-eslint/no-explicit-any": "off",
25 | },
26 | };
27 |
--------------------------------------------------------------------------------
/frontend/src/components/spinner.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | export interface SpinnerProps extends React.SVGProps {
4 | size?: number;
5 | }
6 |
7 | export const Spinner = React.forwardRef(
8 | ({ size = 32, ...props }, ref) => {
9 | return (
10 |
25 | );
26 | },
27 | );
28 |
29 | Spinner.displayName = "Spinner";
30 |
--------------------------------------------------------------------------------
/frontend/src/components/ui/label.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as LabelPrimitive from "@radix-ui/react-label"
3 | import { cva, type VariantProps } from "class-variance-authority"
4 |
5 | import { cn } from "@/lib/utils"
6 |
7 | const labelVariants = cva(
8 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
9 | )
10 |
11 | const Label = React.forwardRef<
12 | React.ElementRef,
13 | React.ComponentPropsWithoutRef &
14 | VariantProps
15 | >(({ className, ...props }, ref) => (
16 |
21 | ))
22 | Label.displayName = LabelPrimitive.Root.displayName
23 |
24 | export { Label }
25 |
--------------------------------------------------------------------------------
/frontend/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2020",
4 | "useDefineForClassFields": true,
5 | "lib": ["ES2020", "DOM", "DOM.Iterable"],
6 | "module": "ESNext",
7 | "skipLibCheck": true,
8 |
9 | /* Bundler mode */
10 | "moduleResolution": "bundler",
11 | "allowImportingTsExtensions": true,
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "noEmit": true,
15 | "jsx": "react-jsx",
16 |
17 | /* Linting */
18 | "strict": true,
19 | "noUnusedLocals": true,
20 | "noUnusedParameters": true,
21 | "noFallthroughCasesInSwitch": true,
22 |
23 | /* shadcn/ui */
24 | "baseUrl": ".",
25 | "paths": {
26 | "@/*": ["./src/*"]
27 | }
28 | },
29 | "include": ["src"],
30 | "references": [{ "path": "./tsconfig.node.json" }]
31 | }
32 |
--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
1 | POSTGRES_USER=zygdev
2 | POSTGRES_PASSWORD=VeryS3cure
3 | POSTGRES_DB=zygdb
4 | DATABASE_HOST=database:5432
5 | DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${DATABASE_HOST}/${POSTGRES_DB}
6 |
7 | REDIS_ADDR=redis:6379
8 | REDIS_USERNAME=zygdev
9 | REDIS_PASSWORD=redispass
10 | REDIS_TLS_ENABLED=0
11 |
12 | # Get these from Supabase console.
13 | SUPABASE_JWT_SECRET=
14 |
15 | # Resend for Email APIs.
16 | RESEND_API_KEY=
17 |
18 | # Cloudflare AccountID
19 | CF_ACCOUNT_ID=
20 |
21 | # Access key for S3 compat R2 storage
22 | R2_ACCESS_KEY_ID=
23 |
24 | # Access secret for S3 compat R2 storage
25 | R2_ACCESS_SECRET_KEY=
26 |
27 | # Set this to 1 if you want db queries in logs.
28 | ZYG_DB_QUERY_DEBUG=0
29 |
30 | # Main API server port
31 | ZYG_SRV_PORT=8080
32 |
33 | # External API server port
34 | ZYG_XSRV_PORT=8000
35 |
36 |
--------------------------------------------------------------------------------
/frontend/src/components/ui/textarea.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | export interface TextareaProps
6 | extends React.TextareaHTMLAttributes {}
7 |
8 | const Textarea = React.forwardRef(
9 | ({ className, ...props }, ref) => {
10 | return (
11 |
19 | )
20 | }
21 | )
22 | Textarea.displayName = "Textarea"
23 |
24 | export { Textarea }
25 |
--------------------------------------------------------------------------------
/frontend/src/routes/_account/workspaces/$workspaceId/_workspace/threads/route.tsx:
--------------------------------------------------------------------------------
1 | import { threadSearchSchema } from "@/lib/search-params";
2 | import { useWorkspaceStore } from "@/providers";
3 | import { createFileRoute, Outlet } from "@tanstack/react-router";
4 | import { zodSearchValidator } from "@tanstack/router-zod-adapter";
5 | import * as React from "react";
6 |
7 | export const Route = createFileRoute(
8 | "/_account/workspaces/$workspaceId/_workspace/threads"
9 | )({
10 | component: ThreadRoute,
11 | validateSearch: zodSearchValidator(threadSearchSchema),
12 | });
13 |
14 | function ThreadRoute() {
15 | const { sort } = Route.useSearch();
16 | const workspaceStore = useWorkspaceStore();
17 |
18 | React.useEffect(() => {
19 | workspaceStore.getState().setThreadSortKey(sort);
20 | }, [sort, workspaceStore]);
21 |
22 | return ;
23 | }
24 |
--------------------------------------------------------------------------------
/frontend/src/components/ui/separator.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as SeparatorPrimitive from "@radix-ui/react-separator"
3 |
4 | import { cn } from "@/lib/utils"
5 |
6 | const Separator = React.forwardRef<
7 | React.ElementRef,
8 | React.ComponentPropsWithoutRef
9 | >(
10 | (
11 | { className, orientation = "horizontal", decorative = true, ...props },
12 | ref
13 | ) => (
14 |
25 | )
26 | )
27 | Separator.displayName = SeparatorPrimitive.Root.displayName
28 |
29 | export { Separator }
30 |
--------------------------------------------------------------------------------
/frontend/src/components/ui/input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | export interface InputProps
6 | extends React.InputHTMLAttributes {}
7 |
8 | const Input = React.forwardRef(
9 | ({ className, type, ...props }, ref) => {
10 | return (
11 |
20 | )
21 | }
22 | )
23 | Input.displayName = "Input"
24 |
25 | export { Input }
26 |
--------------------------------------------------------------------------------
/frontend/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { sentryVitePlugin } from "@sentry/vite-plugin";
2 | import { TanStackRouterVite } from "@tanstack/router-vite-plugin";
3 | import react from "@vitejs/plugin-react-swc";
4 | import path from "path";
5 | import { defineConfig, loadEnv } from "vite";
6 |
7 | // https://vitejs.dev/config/
8 | export default defineConfig(({ mode }) => {
9 | const env = loadEnv(mode, process.cwd(), "");
10 | return {
11 | build: {
12 | sourcemap: true,
13 | },
14 |
15 | plugins: [
16 | react(),
17 | TanStackRouterVite(),
18 | sentryVitePlugin({
19 | org: "zyghq",
20 | project: "zyg-frontend",
21 | telemetry: env.SENTRY_TELEMETRY_ENABLED === "1" || false,
22 | }),
23 | ],
24 |
25 | resolve: {
26 | alias: {
27 | "@": path.resolve(__dirname, "./src"),
28 | },
29 | },
30 |
31 | server: {
32 | port: 3000,
33 | },
34 | };
35 | });
36 |
--------------------------------------------------------------------------------
/frontend/src/components/ui/toaster.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Toast,
3 | ToastClose,
4 | ToastDescription,
5 | ToastProvider,
6 | ToastTitle,
7 | ToastViewport,
8 | } from "@/components/ui/toast"
9 | import { useToast } from "@/components/ui/use-toast"
10 |
11 | export function Toaster() {
12 | const { toasts } = useToast()
13 |
14 | return (
15 |
16 | {toasts.map(function ({ id, title, description, action, ...props }) {
17 | return (
18 |
19 |
20 | {title && {title}}
21 | {description && (
22 | {description}
23 | )}
24 |
25 | {action}
26 |
27 |
28 | )
29 | })}
30 |
31 |
32 | )
33 | }
34 |
--------------------------------------------------------------------------------
/frontend/src/components/notfound.tsx:
--------------------------------------------------------------------------------
1 | import { Link } from "@tanstack/react-router";
2 |
3 | export function NotFound() {
4 | return (
5 |
6 |
7 |
404
8 |
9 | Oops, the page you're looking for doesn't exist.
10 |
11 |
15 | Go to Workspaces
16 |
17 |
18 |
19 | );
20 | }
21 |
--------------------------------------------------------------------------------
/frontend/src/routes/__root.tsx:
--------------------------------------------------------------------------------
1 | import { Toaster } from "@/components/ui/toaster";
2 | import { SupabaseClient } from "@supabase/supabase-js";
3 | import { QueryClient } from "@tanstack/react-query";
4 | import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
5 | import { createRootRouteWithContext, Outlet } from "@tanstack/react-router";
6 | import { TanStackRouterDevtools } from "@tanstack/router-devtools";
7 |
8 | export const Route = createRootRouteWithContext<{
9 | queryClient: QueryClient;
10 | supabaseClient: SupabaseClient;
11 | }>()({
12 | component: RootComponent,
13 | });
14 |
15 | function RootComponent() {
16 | return (
17 | <>
18 |
19 |
20 | {import.meta.env.DEV && (
21 |
22 | )}
23 | {import.meta.env.DEV && (
24 |
25 | )}
26 | >
27 | );
28 | }
29 |
--------------------------------------------------------------------------------
/frontend/src/routes/_account/workspaces/$workspaceId/settings/chat.tsx:
--------------------------------------------------------------------------------
1 | import { Separator } from "@/components/ui/separator";
2 | import { createFileRoute } from "@tanstack/react-router";
3 | import { CookingPotIcon } from "lucide-react";
4 |
5 | export const Route = createFileRoute(
6 | "/_account/workspaces/$workspaceId/settings/chat"
7 | )({
8 | component: ComingSoonSettings,
9 | });
10 |
11 | function ComingSoonSettings() {
12 | return (
13 |
14 |
15 |
21 |
22 |
Will be adding chat settings soon.
23 |
24 |
25 |
26 |
27 | );
28 | }
29 |
--------------------------------------------------------------------------------
/backend/infra/srv/scripts/stop.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | set -e
3 |
4 | # Log all commands for debugging
5 | exec 1> >(logger -s -t $(basename $0)) 2>&1
6 |
7 | echo "Starting application stop script..."
8 |
9 | # Check if the service is running
10 | if systemctl is-active --quiet srv.service; then
11 | echo "Stopping srv service..."
12 | sudo systemctl stop srv.service
13 |
14 | # Wait for the service to fully stop (max 30 seconds)
15 | COUNTER=0
16 | while systemctl is-active --quiet srv.service && [ $COUNTER -lt 30 ]; do
17 | sleep 1
18 | let COUNTER=COUNTER+1
19 | echo "Waiting for service to stop... ($COUNTER seconds)"
20 | done
21 |
22 | # Check if service successfully stopped
23 | if systemctl is-active --quiet srv.service; then
24 | echo "ERROR: Failed to stop the service within timeout period"
25 | exit 1
26 | fi
27 | else
28 | echo "Service was not running"
29 | fi
30 |
31 | echo "Application stop script completed successfully"
32 | exit 0
33 |
--------------------------------------------------------------------------------
/frontend/src/routes/_account/workspaces/$workspaceId/settings/slack.tsx:
--------------------------------------------------------------------------------
1 | import { Separator } from "@/components/ui/separator";
2 | import { createFileRoute } from "@tanstack/react-router";
3 | import { CookingPotIcon } from "lucide-react";
4 |
5 | export const Route = createFileRoute(
6 | "/_account/workspaces/$workspaceId/settings/slack"
7 | )({
8 | component: ComingSoonSettings,
9 | });
10 |
11 | function ComingSoonSettings() {
12 | return (
13 |
14 |
15 |
21 |
22 |
23 | Slack is not yet supported. Will be adding soon.
24 |
25 |
26 |
27 |
28 |
29 | );
30 | }
31 |
--------------------------------------------------------------------------------
/frontend/src/routes/_account/workspaces/$workspaceId/settings/billing.tsx:
--------------------------------------------------------------------------------
1 | import { Separator } from "@/components/ui/separator";
2 | import { createFileRoute } from "@tanstack/react-router";
3 | import { CookingPotIcon } from "lucide-react";
4 |
5 | export const Route = createFileRoute(
6 | "/_account/workspaces/$workspaceId/settings/billing"
7 | )({
8 | component: BillingSettings,
9 | });
10 |
11 | function BillingSettings() {
12 | return (
13 |
14 |
15 |
21 |
22 |
23 | Billing is not yet supported. Will be adding soon.
24 |
25 |
26 |
27 |
28 |
29 | );
30 | }
31 |
--------------------------------------------------------------------------------
/frontend/src/routes/_account/workspaces/$workspaceId/settings/events.tsx:
--------------------------------------------------------------------------------
1 | import { Separator } from "@/components/ui/separator";
2 | import { createFileRoute } from "@tanstack/react-router";
3 | import { CookingPotIcon } from "lucide-react";
4 |
5 | export const Route = createFileRoute(
6 | "/_account/workspaces/$workspaceId/settings/events"
7 | )({
8 | component: ComingSoonSettings,
9 | });
10 |
11 | function ComingSoonSettings() {
12 | return (
13 |
14 |
15 |
21 |
22 |
23 | Events is not yet supported. Will be adding soon.
24 |
25 |
26 |
27 |
28 |
29 | );
30 | }
31 |
--------------------------------------------------------------------------------
/frontend/src/routes/_account/workspaces/$workspaceId/settings/github.tsx:
--------------------------------------------------------------------------------
1 | import { Separator } from "@/components/ui/separator";
2 | import { createFileRoute } from "@tanstack/react-router";
3 | import { CookingPotIcon } from "lucide-react";
4 |
5 | export const Route = createFileRoute(
6 | "/_account/workspaces/$workspaceId/settings/github"
7 | )({
8 | component: ComingSoonSettings,
9 | });
10 |
11 | function ComingSoonSettings() {
12 | return (
13 |
14 |
15 |
21 |
22 |
23 | GitHub is not yet supported. Will be adding soon.
24 |
25 |
26 |
27 |
28 |
29 | );
30 | }
31 |
--------------------------------------------------------------------------------
/frontend/src/routes/_account/workspaces/$workspaceId/settings/linear.tsx:
--------------------------------------------------------------------------------
1 | import { Separator } from "@/components/ui/separator";
2 | import { createFileRoute } from "@tanstack/react-router";
3 | import { CookingPotIcon } from "lucide-react";
4 |
5 | export const Route = createFileRoute(
6 | "/_account/workspaces/$workspaceId/settings/linear"
7 | )({
8 | component: ComingSoonSettings,
9 | });
10 |
11 | function ComingSoonSettings() {
12 | return (
13 |
14 |
15 |
21 |
22 |
23 | Linear is not yet supported. Will be adding soon.
24 |
25 |
26 |
27 |
28 |
29 | );
30 | }
31 |
--------------------------------------------------------------------------------
/frontend/src/routes/_account/workspaces/$workspaceId/settings/webhooks.tsx:
--------------------------------------------------------------------------------
1 | import { Separator } from "@/components/ui/separator";
2 | import { createFileRoute } from "@tanstack/react-router";
3 | import { CookingPotIcon } from "lucide-react";
4 |
5 | export const Route = createFileRoute(
6 | "/_account/workspaces/$workspaceId/settings/webhooks"
7 | )({
8 | component: ComingSoonSettings,
9 | });
10 |
11 | function ComingSoonSettings() {
12 | return (
13 |
14 |
15 |
21 |
22 |
23 | Webhooks is not yet supported. Will be adding soon.
24 |
25 |
26 |
27 |
28 |
29 | );
30 | }
31 |
--------------------------------------------------------------------------------
/frontend/src/routes/_account/workspaces/$workspaceId/settings/notifications.tsx:
--------------------------------------------------------------------------------
1 | import { Separator } from "@/components/ui/separator";
2 | import { CodeIcon } from "@radix-ui/react-icons";
3 | import { createFileRoute } from "@tanstack/react-router";
4 |
5 | export const Route = createFileRoute(
6 | "/_account/workspaces/$workspaceId/settings/notifications"
7 | )({
8 | component: NotificationSettings,
9 | });
10 |
11 | function NotificationSettings() {
12 | return (
13 |
14 |
15 |
16 |
17 |
18 | Your Notifications
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 | Notifications is not yet supported. Will be adding soon.
27 |
28 |
29 |
30 |
31 | );
32 | }
33 |
--------------------------------------------------------------------------------
/frontend/src/components/ui/checkbox.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
3 | import { cn } from "@/lib/utils"
4 | import { CheckIcon } from "@radix-ui/react-icons"
5 |
6 | const Checkbox = React.forwardRef<
7 | React.ElementRef,
8 | React.ComponentPropsWithoutRef
9 | >(({ className, ...props }, ref) => (
10 |
18 |
21 |
22 |
23 |
24 | ))
25 | Checkbox.displayName = CheckboxPrimitive.Root.displayName
26 |
27 | export { Checkbox }
28 |
--------------------------------------------------------------------------------
/backend/adapters/repository/db.go:
--------------------------------------------------------------------------------
1 | package repository
2 |
3 | import (
4 | "github.com/jackc/pgx/v5/pgxpool"
5 | "github.com/redis/go-redis/v9"
6 | "log/slog"
7 | )
8 |
9 | type AccountDB struct {
10 | db *pgxpool.Pool
11 | }
12 |
13 | type WorkspaceDB struct {
14 | db *pgxpool.Pool
15 | }
16 |
17 | type MemberDB struct {
18 | db *pgxpool.Pool
19 | }
20 |
21 | type CustomerDB struct {
22 | db *pgxpool.Pool
23 | }
24 |
25 | type ThreadDB struct {
26 | db *pgxpool.Pool
27 | rdb *redis.Client
28 | }
29 |
30 | func NewAccountDB(db *pgxpool.Pool) *AccountDB {
31 | return &AccountDB{
32 | db: db,
33 | }
34 | }
35 |
36 | func NewWorkspaceDB(db *pgxpool.Pool) *WorkspaceDB {
37 | return &WorkspaceDB{
38 | db: db,
39 | }
40 | }
41 |
42 | func NewMemberDB(db *pgxpool.Pool) *MemberDB {
43 | return &MemberDB{
44 | db: db,
45 | }
46 | }
47 |
48 | func NewCustomerDB(db *pgxpool.Pool) *CustomerDB {
49 | return &CustomerDB{
50 | db: db,
51 | }
52 | }
53 |
54 | func NewThreadDB(db *pgxpool.Pool, rdb *redis.Client) *ThreadDB {
55 | return &ThreadDB{
56 | db: db,
57 | rdb: rdb,
58 | }
59 | }
60 |
61 | func debugQuery(query string) {
62 | slog.Info("db", slog.Any("query", query))
63 | }
64 |
--------------------------------------------------------------------------------
/backend/infra/srv/appspec.yml:
--------------------------------------------------------------------------------
1 | version: 0.0
2 | os: linux
3 |
4 | files:
5 | - source: /bin/app
6 | destination: /usr/local/bin/
7 | permissions:
8 | - object: /usr/local/bin/app
9 | owner: ubuntu
10 | group: ubuntu
11 | mode: 755
12 | type:
13 | - file
14 |
15 | - source: /scripts/srv.service
16 | destination: /etc/systemd/system/
17 | permissions:
18 | - object: /etc/systemd/system/srv.service
19 | owner: ubuntu
20 | group: ubuntu
21 | mode: 644
22 | type:
23 | - file
24 |
25 | hooks:
26 | BeforeInstall:
27 | - location: scripts/before.sh
28 | timeout: 300
29 | runas: root
30 |
31 | AfterInstall:
32 | - location: scripts/after.sh
33 | timeout: 300
34 | runas: root
35 |
36 | ApplicationStop:
37 | - location: scripts/stop.sh
38 | timeout: 300
39 | runas: root
40 |
41 | ApplicationStart:
42 | - location: scripts/start.sh
43 | timeout: 300
44 | runas: root
45 |
46 | ValidateService:
47 | - location: scripts/validate.sh
48 | timeout: 300
49 | runas: ubuntu
50 |
51 | file_exists_behavior: OVERWRITE
52 |
--------------------------------------------------------------------------------
/backend/infra/srv/scripts/start.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | set -e
3 |
4 | # Log all commands for debugging
5 | exec 1> >(logger -s -t $(basename $0)) 2>&1
6 |
7 | echo "Starting application start script..."
8 |
9 | # Verify the application binary exists
10 | if [ ! -f "/usr/local/bin/app" ]; then
11 | echo "ERROR: Application binary not found"
12 | exit 1
13 | fi
14 |
15 | # Ensure proper permissions
16 | sudo chown ubuntu:ubuntu /usr/local/bin/app
17 | sudo chmod 755 /usr/local/bin/app
18 |
19 | # Start the service
20 | echo "Starting srv service..."
21 | sudo systemctl start srv.service
22 |
23 | # Wait for the service to start (max 30 seconds)
24 | COUNTER=0
25 | while ! systemctl is-active --quiet srv.service && [ $COUNTER -lt 30 ]; do
26 | sleep 1
27 | let COUNTER=COUNTER+1
28 | echo "Waiting for service to start... ($COUNTER seconds)"
29 | done
30 |
31 | # Verify the service started successfully
32 | if ! systemctl is-active --quiet srv.service; then
33 | echo "ERROR: Failed to start the service"
34 | # Print the last few lines of the service logs for debugging
35 | echo "Service logs:"
36 | sudo journalctl -u srv.service -n 50 --no-pager
37 | exit 1
38 | fi
39 |
40 | echo "Application start script completed successfully"
41 | exit 0
--------------------------------------------------------------------------------
/frontend/src/components/ui/switch.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as SwitchPrimitives from "@radix-ui/react-switch"
3 |
4 | import { cn } from "@/lib/utils"
5 |
6 | const Switch = React.forwardRef<
7 | React.ElementRef,
8 | React.ComponentPropsWithoutRef
9 | >(({ className, ...props }, ref) => (
10 |
18 |
23 |
24 | ))
25 | Switch.displayName = SwitchPrimitives.Root.displayName
26 |
27 | export { Switch }
28 |
--------------------------------------------------------------------------------
/frontend/src/components/ui/badge.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { cva, type VariantProps } from "class-variance-authority"
3 |
4 | import { cn } from "@/lib/utils"
5 |
6 | const badgeVariants = cva(
7 | "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
8 | {
9 | variants: {
10 | variant: {
11 | default:
12 | "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
13 | secondary:
14 | "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
15 | destructive:
16 | "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
17 | outline: "text-foreground",
18 | },
19 | },
20 | defaultVariants: {
21 | variant: "default",
22 | },
23 | }
24 | )
25 |
26 | export interface BadgeProps
27 | extends React.HTMLAttributes,
28 | VariantProps {}
29 |
30 | function Badge({ className, variant, ...props }: BadgeProps) {
31 | return (
32 |
33 | )
34 | }
35 |
36 | export { Badge, badgeVariants }
37 |
--------------------------------------------------------------------------------
/frontend/src/components/ui/tooltip.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as TooltipPrimitive from "@radix-ui/react-tooltip"
3 |
4 | import { cn } from "@/lib/utils"
5 |
6 | const TooltipProvider = TooltipPrimitive.Provider
7 |
8 | const Tooltip = TooltipPrimitive.Root
9 |
10 | const TooltipTrigger = TooltipPrimitive.Trigger
11 |
12 | const TooltipContent = React.forwardRef<
13 | React.ElementRef,
14 | React.ComponentPropsWithoutRef
15 | >(({ className, sideOffset = 4, ...props }, ref) => (
16 |
17 |
26 |
27 | ))
28 | TooltipContent.displayName = TooltipPrimitive.Content.displayName
29 |
30 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
31 |
--------------------------------------------------------------------------------
/frontend/src/components/theme-toggler.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@/components/ui/button";
2 | import {
3 | DropdownMenu,
4 | DropdownMenuContent,
5 | DropdownMenuItem,
6 | DropdownMenuTrigger,
7 | } from "@/components/ui/dropdown-menu";
8 | import { useTheme } from "@/hooks/theme";
9 | import { MoonIcon, SunIcon } from "@radix-ui/react-icons";
10 |
11 | export function ThemeToggler() {
12 | const { setTheme } = useTheme();
13 | return (
14 |
15 |
16 |
21 |
22 |
23 | setTheme("light")}>
24 | Light
25 |
26 | setTheme("dark")}>
27 | Dark
28 |
29 | setTheme("system")}>
30 | System
31 |
32 |
33 |
34 | );
35 | }
36 |
--------------------------------------------------------------------------------
/frontend/README.md:
--------------------------------------------------------------------------------
1 | # React + TypeScript + Vite
2 |
3 | This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
4 |
5 | Currently, two official plugins are available:
6 |
7 | - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
8 | - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
9 |
10 | ## Expanding the ESLint configuration
11 |
12 | If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
13 |
14 | - Configure the top-level `parserOptions` property like this:
15 |
16 | ```js
17 | export default {
18 | // other rules...
19 | parserOptions: {
20 | ecmaVersion: 'latest',
21 | sourceType: 'module',
22 | project: ['./tsconfig.json', './tsconfig.node.json'],
23 | tsconfigRootDir: __dirname,
24 | },
25 | }
26 | ```
27 |
28 | - Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked`
29 | - Optionally add `plugin:@typescript-eslint/stylistic-type-checked`
30 | - Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list
31 |
--------------------------------------------------------------------------------
/frontend/src/routes/_account/workspaces/$workspaceId/_workspace/search.tsx:
--------------------------------------------------------------------------------
1 | import { Input } from "@/components/ui/input";
2 | import { createFileRoute } from "@tanstack/react-router";
3 | import { Search } from "lucide-react";
4 |
5 | export const Route = createFileRoute(
6 | "/_account/workspaces/$workspaceId/_workspace/search"
7 | )({
8 | component: SearchComponent,
9 | });
10 |
11 | function SearchComponent() {
12 | return (
13 |
14 |
24 |
25 |
26 |
27 |
28 | Search for threads, customers, and more.
29 |
30 |
31 | Search by thread content, customer name, email or external ID.
32 |
33 |
34 |
35 |
36 |
37 | );
38 | }
39 |
--------------------------------------------------------------------------------
/frontend/src/components/ui/popover.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as PopoverPrimitive from "@radix-ui/react-popover"
3 |
4 | import { cn } from "@/lib/utils"
5 |
6 | const Popover = PopoverPrimitive.Root
7 |
8 | const PopoverTrigger = PopoverPrimitive.Trigger
9 |
10 | const PopoverAnchor = PopoverPrimitive.Anchor
11 |
12 | const PopoverContent = React.forwardRef<
13 | React.ElementRef,
14 | React.ComponentPropsWithoutRef
15 | >(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
16 |
17 |
27 |
28 | ))
29 | PopoverContent.displayName = PopoverPrimitive.Content.displayName
30 |
31 | export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
32 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 | (Note: The image above is how the Zyg looks like, perhaps might be outdated as we are adding more enhancements.)
3 |
4 | # Zyg: The OS support infrastructure for B2B companies.
5 |
6 | Get
7 | involved: [Discord](https://discord.gg/dnK9zA5Rz8) • [Website](https://zyg.ai) • [Issues](https://github.com/zyghq/zyg/issues)
8 |
9 | ## Key Features
10 |
11 | - **Widget Support:** Enhance customer support effortlessly with our seamless embeddable widget.
12 | - **RAG Resolve (Todo):** Save time responding to FAQs with AI powered Semantic Search.
13 | - **Conversation Analytics (Todo):** Bring the voice of your customers to product planning and development.
14 | - **more coming soon**...
15 |
16 | ## Built with
17 |
18 | - [Go](https://go.dev/)
19 | - [PostgreSQL](https://www.postgresql.org/)
20 | - [Tailwind CSS](https://tailwindcss.com/)
21 | - [shadcn/ui](https://ui.shadcn.com/)
22 | - [TanStack](https://tanstack.com/)
23 | - [Next.js](https://nextjs.org/)
24 | - [Langchain](https://langchain.com/)
25 | - [Supabase](https://supabase.com/)
26 |
27 | ## Feature Requests
28 |
29 | To request a feature, open a [GitHub issue](https://github.com/zyghq/zyg/issues). If you don't have a GitHub account, you can DM me on [X](https://x.com/_sanchitrk).
30 |
31 |
32 | ## Getting Started for Developers
33 |
34 | Todo;
35 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # If you prefer the allow list template instead of the deny list, see community template:
2 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
3 | #
4 | # Binaries for programs and plugins
5 | *.exe
6 | *.exe~
7 | *.dll
8 | *.so
9 | *.dylib
10 |
11 | # Test binary, built with `go test -c`
12 | *.test
13 |
14 | # Output of the go coverage tool, specifically when used with LiteIDE
15 | *.out
16 |
17 | # Dependency directories (remove the comment below to include it)
18 | # vendor/
19 |
20 | # Go workspace file
21 | go.work
22 |
23 |
24 | # Secrets or Envs
25 | *.env
26 | *.env.local
27 | env.sh
28 | env.local.sh
29 |
30 |
31 | # Python files and directories
32 | **/venv/**
33 | *.pyc
34 | *.pyo
35 | **/__pycache__/**
36 | *.so
37 | *.egg
38 | *.egg-info
39 |
40 |
41 | # Web related files and directories
42 | **/node_modules/**
43 | **/.pnp
44 | **/.pnp.js
45 | **/.yarn/install-state.gz
46 |
47 |
48 | # testing
49 | **/coverage/**
50 |
51 | # next.js
52 | **/.next/**
53 | **/out/**
54 |
55 | # production
56 | **/build/**
57 | **/dist/**
58 |
59 | # misc
60 | **/.DS_Store
61 | *.pem
62 |
63 | # debug
64 | npm-debug.log*
65 | yarn-debug.log*
66 | yarn-error.log*
67 |
68 |
69 | # vercel
70 | **/.vercel/**
71 |
72 | # typescript
73 | *.tsbuildinfo
74 | next-env.d.ts
75 |
76 | # Local directories and files
77 | secrets/
78 | pqdb/
79 | redisdb/
80 |
81 | # IDEs
82 | .idea/
83 |
84 | # Sentry
85 | .env.sentry-build-plugin
86 |
--------------------------------------------------------------------------------
/frontend/public/vite.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/backend/srv.DockerFile:
--------------------------------------------------------------------------------
1 | FROM golang:1.23 AS builder
2 |
3 | ARG ZYG_SRV_PORT=8080
4 | ARG DATABASE_URL
5 | ARG REDIS_ADDR
6 | ARG REDIS_PASSWORD
7 | ARG REDIS_TLS_ENABLED=0
8 | ARG SUPABASE_JWT_SECRET
9 | ARG RESEND_API_KEY
10 | ARG CF_ACCOUNT_ID
11 | ARG R2_ACCESS_KEY_ID
12 | ARG R2_ACCESS_SECRET_KEY
13 | ARG ZYG_DB_QUERY_DEBUG=0
14 |
15 | WORKDIR /usr/src/app
16 |
17 | # Copy only go.mod and go.sum first for better layer caching
18 | COPY go.mod ./
19 | COPY go.sum ./
20 | RUN go mod download && go mod verify
21 |
22 | # Copy the rest of the source code
23 | COPY . .
24 |
25 | RUN CGO_ENABLED=0 GOOS=linux go build -mod=readonly -v -o server ./cmd/srv/main.go
26 |
27 | # Build the runtime container image from scratch, copying what is needed from the previous stage.
28 | FROM alpine:3.21
29 |
30 | # Copy the binary to the production image from the builder stage.
31 | COPY --from=builder /usr/src/app/server /usr/local/bin/server
32 |
33 | ENV ZYG_SRV_PORT=${ZYG_SRV_PORT}
34 | ENV DATABASE_URL=${DATABASE_URL}
35 | ENV REDIS_ADDR=${REDIS_ADDR}
36 | ENV REDIS_USERNAME=${REDIS_USERNAME}
37 | ENV REDIS_TLS_ENABLED=${REDIS_TLS_ENABLED}
38 | ENV SUPABASE_JWT_SECRET=${SUPABASE_JWT_SECRET}
39 | ENV RESEND_API_KEY=${RESEND_API_KEY}
40 | ENV CF_ACCOUNT_ID=${CF_ACCOUNT_ID}
41 | ENV R2_ACCESS_KEY_ID=${R2_ACCESS_KEY_ID}
42 | ENV R2_ACCESS_SECRET_KEY=${R2_ACCESS_SECRET_KEY}
43 | ENV ZYG_DB_QUERY_DEBUG=${ZYG_DB_QUERY_DEBUG}
44 |
45 | EXPOSE ${ZYG_SRV_PORT}
46 |
47 | CMD server -host 0.0.0.0 -port $ZYG_SRV_PORT
48 |
--------------------------------------------------------------------------------
/backend/xsrv.DockerFile:
--------------------------------------------------------------------------------
1 | FROM golang:1.23 AS builder
2 |
3 | ARG ZYG_XSRV_PORT=8000
4 | ARG DATABASE_URL
5 | ARG REDIS_ADDR
6 | ARG REDIS_PASSWORD
7 | ARG REDIS_TLS_ENABLED=0
8 | ARG SUPABASE_JWT_SECRET
9 | ARG RESEND_API_KEY
10 | ARG CF_ACCOUNT_ID
11 | ARG R2_ACCESS_KEY_ID
12 | ARG R2_ACCESS_SECRET_KEY
13 | ARG ZYG_DB_QUERY_DEBUG=0
14 |
15 | WORKDIR /usr/src/app
16 |
17 | # Copy only go.mod and go.sum first for better layer caching
18 | COPY go.mod ./
19 | COPY go.sum ./
20 | RUN go mod download && go mod verify
21 |
22 | # Copy the rest of the source code
23 | COPY . .
24 |
25 | RUN CGO_ENABLED=0 GOOS=linux go build -mod=readonly -v -o server ./cmd/xsrv/main.go
26 |
27 | # Build the runtime container image from scratch, copying what is needed from the previous stage.
28 | FROM alpine:3.21
29 |
30 | # Copy the binary to the production image from the builder stage.
31 | COPY --from=builder /usr/src/app/server /usr/local/bin/server
32 |
33 | ENV ZYG_SRV_PORT=${ZYG_SRV_PORT}
34 | ENV DATABASE_URL=${DATABASE_URL}
35 | ENV REDIS_ADDR=${REDIS_ADDR}
36 | ENV REDIS_USERNAME=${REDIS_USERNAME}
37 | ENV REDIS_TLS_ENABLED=${REDIS_TLS_ENABLED}
38 | ENV SUPABASE_JWT_SECRET=${SUPABASE_JWT_SECRET}
39 | ENV RESEND_API_KEY=${RESEND_API_KEY}
40 | ENV CF_ACCOUNT_ID=${CF_ACCOUNT_ID}
41 | ENV R2_ACCESS_KEY_ID=${R2_ACCESS_KEY_ID}
42 | ENV R2_ACCESS_SECRET_KEY=${R2_ACCESS_SECRET_KEY}
43 | ENV ZYG_DB_QUERY_DEBUG=${ZYG_DB_QUERY_DEBUG}
44 |
45 | EXPOSE ${ZYG_XSRV_PORT}
46 |
47 | CMD server -host 0.0.0.0 -port $ZYG_XSRV_PORT
48 |
--------------------------------------------------------------------------------
/frontend/src/components/workspace/thread/sidepanel-thread-list.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@/components/ui/button";
2 | import { ScrollArea } from "@/components/ui/scroll-area";
3 | import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
4 | import { ThreadList } from "@/components/workspace/thread/threads";
5 | import { Thread } from "@/db/models";
6 | import { PanelLeftIcon } from "lucide-react";
7 | import * as React from "react";
8 |
9 | export function SidePanelThreadList({
10 | threads,
11 | title,
12 | workspaceId,
13 | }: {
14 | threads: Thread[];
15 | title: string;
16 | workspaceId: string;
17 | }) {
18 | const [open, setOpen] = React.useState(false);
19 |
20 | return (
21 |
22 |
23 |
27 |
28 |
29 |
32 |
33 |
38 |
39 |
40 |
41 | );
42 | }
43 |
--------------------------------------------------------------------------------
/frontend/src/routes/_account/workspaces/$workspaceId/_workspace/threads/done.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@/components/ui/button";
2 | import { CodeIcon } from "@radix-ui/react-icons";
3 | import { createFileRoute } from "@tanstack/react-router";
4 | import { MessageCircle } from "lucide-react";
5 |
6 | export const Route = createFileRoute(
7 | "/_account/workspaces/$workspaceId/_workspace/threads/done"
8 | )({
9 | component: () => (
10 |
11 |
12 |
13 |
14 |
15 |
In Development
16 |
17 |
18 |
19 | Soon, any threads marked as done will be shown here.
20 |
21 |
35 |
36 |
37 | ),
38 | });
39 |
--------------------------------------------------------------------------------
/frontend/src/routes/_account/workspaces/$workspaceId/_workspace/threads/labels.$labelId.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@/components/ui/button";
2 | import { CodeIcon } from "@radix-ui/react-icons";
3 | import { createFileRoute } from "@tanstack/react-router";
4 | import { MessageCircle } from "lucide-react";
5 |
6 | export const Route = createFileRoute(
7 | "/_account/workspaces/$workspaceId/_workspace/threads/labels/$labelId"
8 | )({
9 | component: () => (
10 |
11 |
12 |
13 |
14 |
15 |
In Development
16 |
17 |
18 |
19 | Soon you'll be able to list threads by assigned labels.
20 |
21 |
35 |
36 |
37 | ),
38 | });
39 |
--------------------------------------------------------------------------------
/frontend/src/components/ui/radio-group.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { CheckIcon } from "@radix-ui/react-icons"
3 | import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
4 |
5 | import { cn } from "@/lib/utils"
6 |
7 | const RadioGroup = React.forwardRef<
8 | React.ElementRef,
9 | React.ComponentPropsWithoutRef
10 | >(({ className, ...props }, ref) => {
11 | return (
12 |
17 | )
18 | })
19 | RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
20 |
21 | const RadioGroupItem = React.forwardRef<
22 | React.ElementRef,
23 | React.ComponentPropsWithoutRef
24 | >(({ className, ...props }, ref) => {
25 | return (
26 |
34 |
35 |
36 |
37 |
38 | )
39 | })
40 | RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
41 |
42 | export { RadioGroup, RadioGroupItem }
43 |
--------------------------------------------------------------------------------
/frontend/src/components/ui/avatar.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as AvatarPrimitive from "@radix-ui/react-avatar"
3 |
4 | import { cn } from "@/lib/utils"
5 |
6 | const Avatar = React.forwardRef<
7 | React.ElementRef,
8 | React.ComponentPropsWithoutRef
9 | >(({ className, ...props }, ref) => (
10 |
18 | ))
19 | Avatar.displayName = AvatarPrimitive.Root.displayName
20 |
21 | const AvatarImage = React.forwardRef<
22 | React.ElementRef,
23 | React.ComponentPropsWithoutRef
24 | >(({ className, ...props }, ref) => (
25 |
30 | ))
31 | AvatarImage.displayName = AvatarPrimitive.Image.displayName
32 |
33 | const AvatarFallback = React.forwardRef<
34 | React.ElementRef,
35 | React.ComponentPropsWithoutRef
36 | >(({ className, ...props }, ref) => (
37 |
45 | ))
46 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
47 |
48 | export { Avatar, AvatarImage, AvatarFallback }
49 |
--------------------------------------------------------------------------------
/backend/models/member.go:
--------------------------------------------------------------------------------
1 | package models
2 |
3 | import (
4 | "github.com/rs/xid"
5 | "time"
6 | )
7 |
8 | type Member struct {
9 | WorkspaceId string
10 | MemberId string
11 | Name string
12 | Role string
13 | CreatedAt time.Time
14 | UpdatedAt time.Time
15 | }
16 |
17 | func (m Member) GenId() string {
18 | return "mm" + xid.New().String()
19 | }
20 |
21 | func (m Member) IsMemberSystem() bool {
22 | return m.Role == MemberRole{}.System()
23 | }
24 |
25 | func (m Member) AsMemberActor() MemberActor {
26 | return MemberActor{
27 | MemberId: m.MemberId,
28 | Name: m.Name,
29 | }
30 | }
31 |
32 | func (m Member) CreateNewSystemMember(workspaceId string) Member {
33 | now := time.Now().UTC().UTC()
34 | return Member{
35 | MemberId: m.GenId(), // generates a new ID
36 | WorkspaceId: workspaceId,
37 | Name: "System",
38 | Role: MemberRole{}.System(),
39 | CreatedAt: now, // in same time space
40 | UpdatedAt: now, // in same time space
41 | }
42 | }
43 |
44 | type MemberRole struct{}
45 |
46 | func (mr MemberRole) Owner() string {
47 | return "owner"
48 | }
49 |
50 | func (mr MemberRole) System() string {
51 | return "system"
52 | }
53 |
54 | func (mr MemberRole) Admin() string {
55 | return "admin"
56 | }
57 |
58 | func (mr MemberRole) Support() string {
59 | return "support"
60 | }
61 |
62 | func (mr MemberRole) Viewer() string {
63 | return "viewer"
64 | }
65 |
66 | func (mr MemberRole) DefaultRole() string {
67 | return mr.Support()
68 | }
69 |
70 | func (mr MemberRole) IsValid(s string) bool {
71 | switch s {
72 | case mr.Owner(), mr.System(), mr.Admin(), mr.Support(), mr.Viewer():
73 | return true
74 | default:
75 | return false
76 | }
77 | }
78 |
--------------------------------------------------------------------------------
/frontend/src/components/workspace/sorts.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@/components/ui/button";
2 | import {
3 | DropdownMenu,
4 | DropdownMenuContent,
5 | DropdownMenuRadioGroup,
6 | DropdownMenuRadioItem,
7 | DropdownMenuTrigger,
8 | } from "@/components/ui/dropdown-menu";
9 | import { sortKeys, ThreadSortKeyHumanized } from "@/db/helpers";
10 | import { DoubleArrowUpIcon } from "@radix-ui/react-icons";
11 | import React from "react";
12 |
13 | export function Sorts({
14 | onChecked = () => {},
15 | sort,
16 | }: {
17 | onChecked?: (sort: string) => void;
18 | sort: string;
19 | }) {
20 | const [selectedSort, setSelectedSort] = React.useState("");
21 | React.useEffect(() => {
22 | setSelectedSort(sort);
23 | }, [sort]);
24 |
25 | return (
26 |
27 |
28 |
32 |
33 |
34 | e.preventDefault()}
36 | onValueChange={(value) => onChecked(value)}
37 | value={selectedSort}
38 | >
39 | {sortKeys.map((sortKey) => (
40 | e.preventDefault()}
43 | value={sortKey}
44 | >
45 | {ThreadSortKeyHumanized(sortKey)}
46 |
47 | ))}
48 |
49 |
50 |
51 | );
52 | }
53 |
--------------------------------------------------------------------------------
/frontend/src/components/ui/scroll-area.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
3 |
4 | import { cn } from "@/lib/utils"
5 |
6 | const ScrollArea = React.forwardRef<
7 | React.ElementRef,
8 | React.ComponentPropsWithoutRef
9 | >(({ className, children, ...props }, ref) => (
10 |
15 |
16 | {children}
17 |
18 |
19 |
20 |
21 | ))
22 | ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
23 |
24 | const ScrollBar = React.forwardRef<
25 | React.ElementRef,
26 | React.ComponentPropsWithoutRef
27 | >(({ className, orientation = "vertical", ...props }, ref) => (
28 |
41 |
42 |
43 | ))
44 | ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
45 |
46 | export { ScrollArea, ScrollBar }
47 |
--------------------------------------------------------------------------------
/frontend/src/components/ui/alert.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { cva, type VariantProps } from "class-variance-authority"
3 |
4 | import { cn } from "@/lib/utils"
5 |
6 | const alertVariants = cva(
7 | "relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
8 | {
9 | variants: {
10 | variant: {
11 | default: "bg-background text-foreground",
12 | destructive:
13 | "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
14 | },
15 | },
16 | defaultVariants: {
17 | variant: "default",
18 | },
19 | }
20 | )
21 |
22 | const Alert = React.forwardRef<
23 | HTMLDivElement,
24 | React.HTMLAttributes & VariantProps
25 | >(({ className, variant, ...props }, ref) => (
26 |
32 | ))
33 | Alert.displayName = "Alert"
34 |
35 | const AlertTitle = React.forwardRef<
36 | HTMLParagraphElement,
37 | React.HTMLAttributes
38 | >(({ className, ...props }, ref) => (
39 |
44 | ))
45 | AlertTitle.displayName = "AlertTitle"
46 |
47 | const AlertDescription = React.forwardRef<
48 | HTMLParagraphElement,
49 | React.HTMLAttributes
50 | >(({ className, ...props }, ref) => (
51 |
56 | ))
57 | AlertDescription.displayName = "AlertDescription"
58 |
59 | export { Alert, AlertTitle, AlertDescription }
60 |
--------------------------------------------------------------------------------
/backend/adapters/xhandler/middleware.go:
--------------------------------------------------------------------------------
1 | package xhandler
2 |
3 | import (
4 | "log/slog"
5 | "net/http"
6 | "time"
7 |
8 | "github.com/zyghq/zyg/ports"
9 | )
10 |
11 | type wrappedWriter struct {
12 | http.ResponseWriter
13 | statusCode int
14 | }
15 |
16 | func (w *wrappedWriter) WriteHeader(statusCode int) {
17 | w.ResponseWriter.WriteHeader(statusCode)
18 | w.statusCode = statusCode
19 | }
20 |
21 | func LoggingMiddleware(next http.Handler) http.Handler {
22 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
23 | start := time.Now().UTC()
24 | wrapper := &wrappedWriter{
25 | ResponseWriter: w,
26 | statusCode: http.StatusOK, // default
27 | }
28 | next.ServeHTTP(wrapper, r)
29 | slog.Info(
30 | "http",
31 | slog.Int("status", wrapper.statusCode),
32 | slog.String("method", r.Method),
33 | slog.String("path", r.URL.Path),
34 | slog.String("duration", time.Since(start).String()),
35 | )
36 | })
37 | }
38 |
39 | type EnsureAuth struct {
40 | handler AuthenticatedHandler
41 | authz ports.CustomerAuthServicer
42 | }
43 |
44 | func NewEnsureAuth(handler AuthenticatedHandler, as ports.CustomerAuthServicer) *EnsureAuth {
45 | return &EnsureAuth{
46 | handler: handler,
47 | authz: as,
48 | }
49 | }
50 |
51 | func (ea *EnsureAuth) ServeHTTP(w http.ResponseWriter, r *http.Request) {
52 | scheme, cred, err := CheckAuthCredentials(r)
53 | if err != nil {
54 | http.Error(w, err.Error(), http.StatusUnauthorized)
55 | return
56 | }
57 | widgetId := r.PathValue("widgetId")
58 | customer, err := AuthenticateCustomer(r.Context(), ea.authz, scheme, cred, widgetId)
59 | if err != nil {
60 | slog.Error("failed to authenticate customer for widget", slog.Any("error", err))
61 | http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
62 | return
63 | }
64 | ea.handler(w, r, &customer)
65 | }
66 |
--------------------------------------------------------------------------------
/frontend/src/components/ui/resizable.tsx:
--------------------------------------------------------------------------------
1 | import { DragHandleDots2Icon } from "@radix-ui/react-icons"
2 | import * as ResizablePrimitive from "react-resizable-panels"
3 |
4 | import { cn } from "@/lib/utils"
5 |
6 | const ResizablePanelGroup = ({
7 | className,
8 | ...props
9 | }: React.ComponentProps) => (
10 |
17 | )
18 |
19 | const ResizablePanel = ResizablePrimitive.Panel
20 |
21 | const ResizableHandle = ({
22 | withHandle,
23 | className,
24 | ...props
25 | }: React.ComponentProps & {
26 | withHandle?: boolean
27 | }) => (
28 | div]:rotate-90",
31 | className
32 | )}
33 | {...props}
34 | >
35 | {withHandle && (
36 |
37 |
38 |
39 | )}
40 |
41 | )
42 |
43 | export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
44 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | volumes:
2 | database-data:
3 |
4 | networks:
5 | stack:
6 | name: stack
7 | external: false
8 |
9 | services:
10 | database:
11 | container_name: postgres
12 | image: postgres
13 | restart: always
14 | volumes:
15 | - database-data:/var/lib/postgresql/data/
16 | env_file: .env
17 | networks:
18 | - stack
19 | ports:
20 | - "5432:5432"
21 |
22 | redis:
23 | container_name: redis
24 | image: redis
25 | restart: always
26 | command: redis-server --requirepass ${REDIS_PASSWORD} --user ${REDIS_USERNAME} on '>${REDIS_PASSWORD}' '~*' allcommands
27 | env_file: .env
28 | networks:
29 | - stack
30 | ports:
31 | - "6379:6379"
32 |
33 | srv:
34 | container_name: srv
35 | build:
36 | context: ./backend
37 | dockerfile: srv.DockerFile
38 | args:
39 | - ZYG_SRV_PORT=${ZYG_SRV_PORT}
40 | - DATABASE_URL=${DATABASE_URL}
41 | - SUPABASE_JWT_SECRET=${SUPABASE_JWT_SECRET}
42 | - RESEND_API_KEY=${RESEND_API_KEY}
43 | - ZYG_DB_QUERY_DEBUG=${ZYG_DB_QUERY_DEBUG}
44 | restart: always
45 | depends_on:
46 | - database
47 | - redis
48 | env_file: .env
49 | networks:
50 | - stack
51 | ports:
52 | - "${ZYG_SRV_PORT}:${ZYG_SRV_PORT}"
53 | profiles:
54 | - server
55 |
56 | xsrv:
57 | container_name: xsrv
58 | build:
59 | context: ./backend
60 | dockerfile: xsrv.DockerFile
61 | args:
62 | - ZYG_XSRV_PORT=${ZYG_XSRV_PORT}
63 | - DATABASE_URL=${DATABASE_URL}
64 | - SUPABASE_JWT_SECRET=${SUPABASE_JWT_SECRET}
65 | - RESEND_API_KEY=${RESEND_API_KEY}
66 | - ZYG_DB_QUERY_DEBUG=${ZYG_DB_QUERY_DEBUG}
67 | restart: always
68 | depends_on:
69 | - database
70 | - redis
71 | env_file: .env
72 | networks:
73 | - stack
74 | ports:
75 | - "${ZYG_XSRV_PORT}:${ZYG_XSRV_PORT}"
76 | profiles:
77 | - server
78 |
--------------------------------------------------------------------------------
/frontend/src/components/workspace/sidenav-mobile-links.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@/components/ui/button";
2 | import {
3 | Sheet,
4 | SheetContent,
5 | SheetDescription,
6 | SheetHeader,
7 | SheetTitle,
8 | SheetTrigger,
9 | } from "@/components/ui/sheet";
10 | import SideNavLinks from "@/components/workspace/sidenav-links";
11 | import { WorkspaceMetrics } from "@/db/models";
12 | import { SortBy } from "@/db/store.ts";
13 | import { HamburgerMenuIcon } from "@radix-ui/react-icons";
14 | import React from "react";
15 |
16 | export default function SideNavMobileLinks({
17 | email,
18 | memberId,
19 | metrics,
20 | sort,
21 | workspaceId,
22 | workspaceName,
23 | }: {
24 | email: string;
25 | memberId: string;
26 | metrics: WorkspaceMetrics;
27 | sort: SortBy
28 | workspaceId: string;
29 | workspaceName: string;
30 | }) {
31 | const [open, setOpen] = React.useState(false);
32 | return (
33 |
34 |
35 |
42 |
43 |
44 | {/* adding this to stop aria warnings. */}
45 |
46 | Open Menu
47 |
48 | Select menu items from the left sidebar to navigate to different
49 | pages.
50 |
51 |
52 |
62 |
63 |
64 | );
65 | }
66 |
--------------------------------------------------------------------------------
/frontend/src/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { Slot } from "@radix-ui/react-slot"
3 | import { cva, type VariantProps } from "class-variance-authority"
4 |
5 | import { cn } from "@/lib/utils"
6 |
7 | const buttonVariants = cva(
8 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
9 | {
10 | variants: {
11 | variant: {
12 | default:
13 | "bg-primary text-primary-foreground shadow hover:bg-primary/90",
14 | destructive:
15 | "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
16 | outline:
17 | "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
18 | secondary:
19 | "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
20 | ghost: "hover:bg-accent hover:text-accent-foreground",
21 | link: "text-primary underline-offset-4 hover:underline",
22 | },
23 | size: {
24 | default: "h-9 px-4 py-2",
25 | sm: "h-8 rounded-md px-3 text-xs",
26 | lg: "h-10 rounded-md px-8",
27 | icon: "h-9 w-9",
28 | },
29 | },
30 | defaultVariants: {
31 | variant: "default",
32 | size: "default",
33 | },
34 | }
35 | )
36 |
37 | export interface ButtonProps
38 | extends React.ButtonHTMLAttributes,
39 | VariantProps {
40 | asChild?: boolean
41 | }
42 |
43 | const Button = React.forwardRef(
44 | ({ className, variant, size, asChild = false, ...props }, ref) => {
45 | const Comp = asChild ? Slot : "button"
46 | return (
47 |
52 | )
53 | }
54 | )
55 | Button.displayName = "Button"
56 |
57 | export { Button, buttonVariants }
58 |
--------------------------------------------------------------------------------
/frontend/src/components/ui/tabs.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as TabsPrimitive from "@radix-ui/react-tabs"
3 |
4 | import { cn } from "@/lib/utils"
5 |
6 | const Tabs = TabsPrimitive.Root
7 |
8 | const TabsList = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, ...props }, ref) => (
12 |
20 | ))
21 | TabsList.displayName = TabsPrimitive.List.displayName
22 |
23 | const TabsTrigger = React.forwardRef<
24 | React.ElementRef,
25 | React.ComponentPropsWithoutRef
26 | >(({ className, ...props }, ref) => (
27 |
35 | ))
36 | TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
37 |
38 | const TabsContent = React.forwardRef<
39 | React.ElementRef,
40 | React.ComponentPropsWithoutRef
41 | >(({ className, ...props }, ref) => (
42 |
50 | ))
51 | TabsContent.displayName = TabsPrimitive.Content.displayName
52 |
53 | export { Tabs, TabsList, TabsTrigger, TabsContent }
54 |
--------------------------------------------------------------------------------
/backend/adapters/xhandler/auth.go:
--------------------------------------------------------------------------------
1 | package xhandler
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "fmt"
7 | "log/slog"
8 | "net/http"
9 | "strings"
10 |
11 | "github.com/zyghq/zyg/models"
12 | "github.com/zyghq/zyg/ports"
13 | "github.com/zyghq/zyg/services"
14 | )
15 |
16 | func CheckAuthCredentials(r *http.Request) (string, string, error) {
17 | authHeader := r.Header.Get("Authorization")
18 | if authHeader == "" {
19 | return "", "", fmt.Errorf("no authorization header provided")
20 | }
21 | parts := strings.Split(authHeader, " ")
22 | if len(parts) != 2 {
23 | return "", "", fmt.Errorf("invalid token")
24 | }
25 | scheme := strings.ToLower(parts[0])
26 | return scheme, parts[1], nil
27 | }
28 |
29 | func AuthenticateCustomer(
30 | ctx context.Context, authz ports.CustomerAuthServicer,
31 | scheme string, cred string, widgetId string) (models.Customer, error) {
32 | var customer models.Customer
33 | if scheme == "bearer" {
34 | sk, err := authz.GetWidgetLinkedSecretKey(ctx, widgetId)
35 | if err != nil {
36 | return customer, fmt.Errorf("got no secret key for workspace widget %s: %v", widgetId, err)
37 | }
38 |
39 | cc, err := services.ParseCustomerJWTToken(cred, []byte(sk.Hmac))
40 | if err != nil {
41 | return customer, fmt.Errorf("customer jwt invalid: %v", err)
42 | }
43 |
44 | sub, err := cc.RegisteredClaims.GetSubject()
45 | if err != nil {
46 | return customer, fmt.Errorf("%v", err)
47 | }
48 |
49 | customer, err = authz.AuthenticateWorkspaceCustomer(ctx, cc.WorkspaceId, sub, nil)
50 | if errors.Is(err, services.ErrCustomerNotFound) {
51 | return customer, fmt.Errorf("authenticated sub customer not found")
52 | }
53 | if err != nil {
54 | slog.Error("failed to fetch customer", slog.Any("err", err))
55 | return customer, fmt.Errorf("failed to validate customer with customer id: %s got error: %v", sub, err)
56 | }
57 | return customer, nil
58 | } else {
59 | return customer, fmt.Errorf("unsupported scheme: `%s` cannot authenticate", scheme)
60 | }
61 | }
62 |
63 | type AuthenticatedHandler func(http.ResponseWriter, *http.Request, *models.Customer)
64 |
--------------------------------------------------------------------------------
/frontend/src/components/ui/card.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | const Card = React.forwardRef<
6 | HTMLDivElement,
7 | React.HTMLAttributes
8 | >(({ className, ...props }, ref) => (
9 |
17 | ))
18 | Card.displayName = "Card"
19 |
20 | const CardHeader = React.forwardRef<
21 | HTMLDivElement,
22 | React.HTMLAttributes
23 | >(({ className, ...props }, ref) => (
24 |
29 | ))
30 | CardHeader.displayName = "CardHeader"
31 |
32 | const CardTitle = React.forwardRef<
33 | HTMLParagraphElement,
34 | React.HTMLAttributes
35 | >(({ className, ...props }, ref) => (
36 |
41 | ))
42 | CardTitle.displayName = "CardTitle"
43 |
44 | const CardDescription = React.forwardRef<
45 | HTMLParagraphElement,
46 | React.HTMLAttributes
47 | >(({ className, ...props }, ref) => (
48 |
53 | ))
54 | CardDescription.displayName = "CardDescription"
55 |
56 | const CardContent = React.forwardRef<
57 | HTMLDivElement,
58 | React.HTMLAttributes
59 | >(({ className, ...props }, ref) => (
60 |
61 | ))
62 | CardContent.displayName = "CardContent"
63 |
64 | const CardFooter = React.forwardRef<
65 | HTMLDivElement,
66 | React.HTMLAttributes
67 | >(({ className, ...props }, ref) => (
68 |
73 | ))
74 | CardFooter.displayName = "CardFooter"
75 |
76 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
77 |
--------------------------------------------------------------------------------
/frontend/src/components/workspace/settings/sidenav-mobile-links.tsx:
--------------------------------------------------------------------------------
1 | import { Button, buttonVariants } from "@/components/ui/button";
2 | import {
3 | Sheet,
4 | SheetContent,
5 | SheetDescription,
6 | SheetHeader,
7 | SheetTitle,
8 | SheetTrigger,
9 | } from "@/components/ui/sheet";
10 | import SideNavLinks from "@/components/workspace/settings/sidenav-links";
11 | import { cn } from "@/lib/utils";
12 | import { ArrowLeftIcon, HamburgerMenuIcon } from "@radix-ui/react-icons";
13 | import { Link } from "@tanstack/react-router";
14 | import React from "react";
15 |
16 | export default function SideNavMobileLinks({
17 | accountId,
18 | accountName,
19 | workspaceId,
20 | }: {
21 | accountId: string;
22 | accountName: string;
23 | workspaceId: string;
24 | }) {
25 | const [open, setOpen] = React.useState(false);
26 | return (
27 |
28 |
29 |
36 |
37 |
38 | {/* adding this to stop aria warnings. */}
39 |
40 | Open Menu
41 |
42 | Select menu items from the left sidebar to navigate to different
43 | pages.
44 |
45 |
46 |
47 |
48 |
53 |
54 | Settings
55 |
56 |
57 |
58 |
63 |
64 |
65 | );
66 | }
67 |
--------------------------------------------------------------------------------
/frontend/src/routes/_account/workspaces/$workspaceId.tsx:
--------------------------------------------------------------------------------
1 | import { bootstrapWorkspace } from "@/db/api";
2 | import { WorkspaceStoreProvider } from "@/providers";
3 | import { queryOptions, useQuery } from "@tanstack/react-query";
4 | import { createFileRoute, Outlet, redirect } from "@tanstack/react-router";
5 |
6 | const bootstrapWorkspaceQueryOptions = (token: string, workspaceId: string) =>
7 | queryOptions({
8 | queryFn: async () => {
9 | const data = await bootstrapWorkspace(token, workspaceId);
10 | const { error } = data;
11 | if (error) throw new Error("failed to fetch workspace details.");
12 | return data;
13 | },
14 | queryKey: ["workspaceStore", token, workspaceId],
15 | });
16 |
17 | export const Route = createFileRoute("/_account/workspaces/$workspaceId")({
18 | component: Workspace,
19 | // check if we need this, add some kind of stale timer.
20 | // https://tanstack.com/router/latest/docs/framework/react/guide/data-loading#using-staletime-to-control-how-long-data-is-considered-fresh
21 | loader: async ({
22 | context: { queryClient, supabaseClient },
23 | params: { workspaceId },
24 | }) => {
25 | const { data, error } = await supabaseClient.auth.getSession();
26 | if (error || !data?.session) throw redirect({ to: "/signin" });
27 | const token = data.session.access_token as string;
28 | return queryClient.ensureQueryData(
29 | bootstrapWorkspaceQueryOptions(token, workspaceId),
30 | );
31 | },
32 | });
33 |
34 | function Workspace() {
35 | const { token } = Route.useRouteContext();
36 | const { workspaceId } = Route.useParams();
37 | const initialData = Route.useLoaderData();
38 |
39 | const response = useQuery({
40 | enabled: !!token && !!workspaceId,
41 | initialData: initialData,
42 | queryFn: async () => {
43 | const data = await bootstrapWorkspace(token, workspaceId);
44 | const { error } = data;
45 | if (error) throw new Error("failed to fetch workspace details.");
46 | return data;
47 | },
48 | queryKey: ["workspaceStore", token, workspaceId],
49 | staleTime: 1000 * 60 * 3,
50 | });
51 |
52 | const { data } = response;
53 |
54 | return (
55 |
56 |
57 |
58 | );
59 | }
60 |
--------------------------------------------------------------------------------
/backend/services/error.go:
--------------------------------------------------------------------------------
1 | package services
2 |
3 | type serviceErr string
4 |
5 | func (err serviceErr) Error() string {
6 | return string(err)
7 | }
8 |
9 | const (
10 | ErrAccount = serviceErr("account error")
11 | ErrAccountNotFound = serviceErr("account not found")
12 |
13 | ErrPat = serviceErr("pat error")
14 | ErrPatNotFound = serviceErr("pat not found")
15 |
16 | ErrWorkspace = serviceErr("workspace error")
17 | ErrWorkspaceNotFound = serviceErr("workspace not found")
18 |
19 | ErrMember = serviceErr("member error")
20 | ErrMemberNotFound = serviceErr("member not found")
21 |
22 | ErrLabel = serviceErr("label error")
23 | ErrLabelNotFound = serviceErr("label not found")
24 |
25 | ErrThreadChat = serviceErr("thread chat error")
26 |
27 | ErrThread = serviceErr("thread error")
28 | ErrThreadNotFound = serviceErr("thread not found")
29 |
30 | ErrThreadMetrics = serviceErr("thread chat metrics error")
31 |
32 | ErrThreadMessage = serviceErr("thread message error")
33 |
34 | ErrCustomer = serviceErr("customer error")
35 | ErrCustomerNotFound = serviceErr("customer not found")
36 |
37 | ErrSecretKeyNotFound = serviceErr("secret key not found")
38 | ErrSecretKey = serviceErr("secret key error")
39 |
40 | ErrWidget = serviceErr("widget error")
41 | ErrWidgetNotFound = serviceErr("widget not found")
42 |
43 | ErrWidgetSession = serviceErr("widget session error")
44 | ErrWidgetSessionInvalid = serviceErr("widget session invalid")
45 |
46 | ErrClaimedMail = serviceErr("claimed mail error")
47 | ErrClaimedMailNotFound = serviceErr("claimed mail not found")
48 | ErrClaimedMailExpired = serviceErr("claimed mail expired")
49 |
50 | ErrCustomerEvent = serviceErr("customer event error")
51 |
52 | ErrMessageAttachment = serviceErr("message attachment error")
53 | ErrMessageAttachmentNotFound = serviceErr("message attachment not found")
54 |
55 | ErrPostmarkSettingNotFound = serviceErr("postmark setting not found")
56 | ErrPostmarkSetting = serviceErr("postmark setting error")
57 |
58 | ErrPostmarkLog = serviceErr("postmark log error")
59 | ErrPostmarkLogNotFound = serviceErr("postmark log not found")
60 | ErrPostmarkInbound = serviceErr("postmark inbound error")
61 | ErrPostmarkOutbound = serviceErr("postmark outbound error")
62 | )
63 |
--------------------------------------------------------------------------------
/backend/adapters/store/s3store.go:
--------------------------------------------------------------------------------
1 | package store
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "github.com/aws/aws-sdk-go-v2/aws"
7 | "github.com/aws/aws-sdk-go-v2/config"
8 | "github.com/aws/aws-sdk-go-v2/credentials"
9 | "github.com/aws/aws-sdk-go-v2/service/s3"
10 | "time"
11 | )
12 |
13 | type S3Config struct {
14 | Client *s3.Client
15 | BucketName string
16 | BaseEndpoint string
17 | }
18 |
19 | // NewS3 creates a new S3Config with Cloudflare R2 storage configuration
20 | // Parameters:
21 | //
22 | // bucketName - Name of the R2 bucket to use
23 | // accountId - Cloudflare account ID
24 | // accessKeyId - R2 access key ID
25 | // accessKeySecret - R2 access key secret
26 | //
27 | // Returns:
28 | //
29 | // S3Config - Configuration for R2 storage operations
30 | // error - Any error that occurred during setup
31 | func NewS3(ctx context.Context, bucketName, accountId, accessKeyId, accessKeySecret string) (S3Config, error) {
32 | if bucketName == "" || accountId == "" || accessKeyId == "" || accessKeySecret == "" {
33 | return S3Config{}, fmt.Errorf("s3 parameters are required")
34 | }
35 |
36 | cfg, err := config.LoadDefaultConfig(ctx,
37 | config.WithCredentialsProvider(
38 | credentials.NewStaticCredentialsProvider(accessKeyId, accessKeySecret, "")),
39 | config.WithRegion("auto"),
40 | )
41 | if err != nil {
42 | return S3Config{}, fmt.Errorf("unable to load S3 config: %v", err)
43 | }
44 |
45 | baseEndpoint := fmt.Sprintf("https://%s.r2.cloudflarestorage.com", accountId)
46 | client := s3.NewFromConfig(cfg, func(o *s3.Options) {
47 | o.BaseEndpoint = aws.String(baseEndpoint)
48 | })
49 |
50 | return S3Config{
51 | Client: client,
52 | BucketName: bucketName,
53 | BaseEndpoint: baseEndpoint,
54 | }, nil
55 | }
56 |
57 | func PresignedUrl(ctx context.Context, s3Client S3Config, key string, expiresIn time.Time) (string, error) {
58 | input := &s3.GetObjectInput{
59 | Bucket: aws.String(s3Client.BucketName),
60 | Key: aws.String(key),
61 | }
62 |
63 | presignClient := s3.NewPresignClient(s3Client.Client)
64 |
65 | // Calculate duration from now until expiresIn time
66 | duration := time.Until(expiresIn)
67 |
68 | presignedReq, err := presignClient.PresignGetObject(ctx, input,
69 | s3.WithPresignExpires(duration),
70 | )
71 | if err != nil {
72 | return "", err
73 | }
74 | return presignedReq.URL, nil
75 | }
76 |
--------------------------------------------------------------------------------
/backend/services/tasks/mail.go:
--------------------------------------------------------------------------------
1 | package tasks
2 |
3 | import (
4 | "bytes"
5 | "fmt"
6 | "html/template"
7 | "log/slog"
8 |
9 | "github.com/resend/resend-go/v2"
10 | "github.com/zyghq/zyg"
11 | )
12 |
13 | type KycMailData struct {
14 | PreviewText string
15 | MagicLink string
16 | }
17 |
18 | func SendKycMail(to string, body string, verifyLink string) error {
19 | subject := "Started a new chat on Zyg."
20 | htmlTempl, err := template.ParseFiles("static/templates/mails/kyc.html")
21 | if err != nil {
22 | slog.Error("error parsing html template file", slog.Any("err", err))
23 | return err
24 | }
25 | // Read text template for non html text content.
26 | textTempl, err := template.ParseFiles("static/templates/mails/text/kyc.txt")
27 | if err != nil {
28 | slog.Error("error parsing text template file", slog.Any("err", err))
29 | return err
30 | }
31 |
32 | data := KycMailData{
33 | PreviewText: body,
34 | MagicLink: verifyLink,
35 | }
36 |
37 | var htmlTemplOutput bytes.Buffer
38 | err = htmlTempl.Execute(&htmlTemplOutput, data)
39 | if err != nil {
40 | slog.Error("error executing html template", slog.Any("err", err))
41 | return err
42 | }
43 | htmlOutput := htmlTemplOutput.String()
44 |
45 | var textTemplOutput bytes.Buffer
46 | err = textTempl.Execute(&textTemplOutput, data)
47 | if err != nil {
48 | slog.Error("error executing text template", slog.Any("err", err))
49 | return err
50 | }
51 | textOutput := textTemplOutput.String()
52 |
53 | fmt.Println("************* HTML FOR MAIL **************")
54 | fmt.Println(htmlOutput)
55 | fmt.Println("************* END HTML FOR MAIL **************")
56 |
57 | fmt.Println("******* send mail to **********")
58 | fmt.Println(to)
59 | fmt.Println(subject)
60 | fmt.Println(textOutput)
61 | fmt.Println("******* END send mail to **********")
62 |
63 | apiKey := zyg.ResendApiKey()
64 | client := resend.NewClient(apiKey)
65 |
66 | params := &resend.SendEmailRequest{
67 | From: "Sanchit ",
68 | To: []string{to},
69 | Subject: subject,
70 | Html: htmlOutput,
71 | Text: textOutput,
72 | ReplyTo: "sanchit@zyg.ai",
73 | }
74 |
75 | sent, err := client.Emails.Send(params)
76 | if err != nil {
77 | slog.Error("failed to send email", slog.Any("err", err))
78 | return err
79 | }
80 |
81 | slog.Info("sent email", slog.Any("Id", sent.Id))
82 |
83 | return nil
84 | }
85 |
--------------------------------------------------------------------------------
/frontend/src/routes/_account/workspaces/$workspaceId/_workspace/route.tsx:
--------------------------------------------------------------------------------
1 | import { Header } from "@/components/workspace/header";
2 | import SideNavLinks from "@/components/workspace/sidenav-links";
3 | import { WorkspaceStoreState } from "@/db/store";
4 | import { useAccountStore, useWorkspaceStore } from "@/providers";
5 | import { createFileRoute, Outlet } from "@tanstack/react-router";
6 | import * as React from "react";
7 | import { useStore } from "zustand";
8 |
9 | export const Route = createFileRoute(
10 | "/_account/workspaces/$workspaceId/_workspace",
11 | )({
12 | component: WorkspaceLayout,
13 | });
14 |
15 | function WorkspaceLayout() {
16 | const accountStore = useAccountStore();
17 | const workspaceStore = useWorkspaceStore();
18 |
19 | const email = useStore(accountStore, (state) => state.getEmail(state));
20 | const workspaceId = useStore(workspaceStore, (state: WorkspaceStoreState) =>
21 | state.getWorkspaceId(state),
22 | );
23 | const workspaceName = useStore(workspaceStore, (state: WorkspaceStoreState) =>
24 | state.getWorkspaceName(state),
25 | );
26 | const memberId = useStore(workspaceStore, (state: WorkspaceStoreState) =>
27 | state.getMemberId(state),
28 | );
29 | const metrics = useStore(workspaceStore, (state: WorkspaceStoreState) =>
30 | state.getMetrics(state),
31 | );
32 |
33 | const sort = useStore(workspaceStore, (state) =>
34 | state.viewThreadSortKey(state),
35 | );
36 |
37 | return (
38 |
39 |
47 |
63 |
64 | );
65 | }
66 |
--------------------------------------------------------------------------------
/frontend/src/components/workspace/header.tsx:
--------------------------------------------------------------------------------
1 | import { Icons } from "@/components/icons";
2 | import { ThemeToggler } from "@/components/theme-toggler";
3 | import { buttonVariants } from "@/components/ui/button";
4 | import SideNavMobileLinks from "@/components/workspace/sidenav-mobile-links";
5 | import { WorkspaceMetrics } from "@/db/models";
6 | import { SortBy } from "@/db/store";
7 | import { cn } from "@/lib/utils";
8 | import { Link } from "@tanstack/react-router";
9 | import { ArrowLeftRightIcon } from "lucide-react";
10 |
11 | export function Header({
12 | email,
13 | memberId,
14 | metrics,
15 | sort,
16 | workspaceId,
17 | workspaceName,
18 | }: {
19 | email: string;
20 | memberId: string;
21 | metrics: WorkspaceMetrics;
22 | sort: SortBy,
23 | workspaceId: string;
24 | workspaceName: string;
25 | }) {
26 | return (
27 |
64 | );
65 | }
66 |
--------------------------------------------------------------------------------
/backend/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/zyghq/zyg
2 |
3 | go 1.23
4 |
5 | toolchain go1.23.2
6 |
7 | require (
8 | github.com/JohannesKaufmann/html-to-markdown/v2 v2.1.0
9 | github.com/aws/aws-sdk-go-v2 v1.32.6
10 | github.com/aws/aws-sdk-go-v2/config v1.28.6
11 | github.com/aws/aws-sdk-go-v2/credentials v1.17.47
12 | github.com/aws/aws-sdk-go-v2/service/s3 v1.68.0
13 | github.com/cristalhq/builq v0.15.0
14 | github.com/getsentry/sentry-go v0.30.0
15 | github.com/golang-jwt/jwt/v5 v5.2.1
16 | github.com/google/uuid v1.6.0
17 | github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438
18 | github.com/jackc/pgx/v5 v5.5.5
19 | github.com/redis/go-redis/v9 v9.7.0
20 | github.com/resend/resend-go/v2 v2.12.0
21 | github.com/rs/cors v1.10.1
22 | github.com/rs/xid v1.5.0
23 | github.com/sanchitrk/namingo v0.1.2
24 | github.com/zyghq/postmark v0.0.0-20241222082503-a96065eb030e
25 | golang.org/x/net v0.31.0
26 | )
27 |
28 | require (
29 | github.com/JohannesKaufmann/dom v0.1.1-0.20240706125338-ff9f3b772364 // indirect
30 | github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.7 // indirect
31 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.21 // indirect
32 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.25 // indirect
33 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.25 // indirect
34 | github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 // indirect
35 | github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.25 // indirect
36 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1 // indirect
37 | github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.4.6 // indirect
38 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.6 // indirect
39 | github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.6 // indirect
40 | github.com/aws/aws-sdk-go-v2/service/sso v1.24.7 // indirect
41 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.6 // indirect
42 | github.com/aws/aws-sdk-go-v2/service/sts v1.33.2 // indirect
43 | github.com/aws/smithy-go v1.22.1 // indirect
44 | github.com/cespare/xxhash/v2 v2.2.0 // indirect
45 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
46 | github.com/jackc/pgpassfile v1.0.0 // indirect
47 | github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
48 | github.com/jackc/puddle/v2 v2.2.2 // indirect
49 | golang.org/x/crypto v0.29.0 // indirect
50 | golang.org/x/sync v0.9.0 // indirect
51 | golang.org/x/sys v0.27.0 // indirect
52 | golang.org/x/text v0.20.0 // indirect
53 | )
54 |
--------------------------------------------------------------------------------
/frontend/src/components/thread/customer-events.tsx:
--------------------------------------------------------------------------------
1 | import { RenderComponents } from "@/components/event/components";
2 | import { eventSeverityIcon } from "@/components/icons";
3 | import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
4 | import { Card, CardContent, CardHeader } from "@/components/ui/card";
5 | import { CustomerEventResponse } from "@/db/schema";
6 | import { ClockIcon } from "@radix-ui/react-icons";
7 | import { DefaultError } from "@tanstack/react-query";
8 | import { AlertCircle } from "lucide-react";
9 |
10 | function EventError() {
11 | return (
12 |
13 |
14 | Error
15 | Something went wrong.
16 |
17 | );
18 | }
19 |
20 | export function CustomerEvents({
21 | error,
22 | events,
23 | }: {
24 | error: DefaultError | null;
25 | events: CustomerEventResponse[];
26 | }) {
27 | if (error) {
28 | return ;
29 | }
30 |
31 | if (events && events.length > 0) {
32 | return (
33 |
34 | {events.map((event) => (
35 |
36 | ))}
37 |
38 | );
39 | }
40 | return null;
41 | }
42 |
43 | export function EventCard({ event }: { event: CustomerEventResponse }) {
44 | const formatDate = (dateString: string) => {
45 | const date = new Date(dateString);
46 | return date.toLocaleString("en-US", {
47 | day: "numeric",
48 | hour: "2-digit",
49 | hour12: true,
50 | minute: "2-digit",
51 | month: "short",
52 | });
53 | };
54 | return (
55 |
56 |
57 |
58 | {eventSeverityIcon(event.severity, {
59 | className: "h-5 w-5",
60 | })}
61 |
62 | {event.title}
63 |
64 |
65 | {formatDate(event.timestamp)}
66 |
67 |
68 |
69 |
70 |
71 | {RenderComponents({ components: event.components })}
72 |
73 |
74 | );
75 | }
76 |
--------------------------------------------------------------------------------
/frontend/src/routes/_account/route.tsx:
--------------------------------------------------------------------------------
1 | import { getOrCreateZygAccount } from "@/db/api";
2 | import { AccountStoreProvider } from "@/providers";
3 | import { queryOptions, useQuery } from "@tanstack/react-query";
4 | import { createFileRoute, Outlet, redirect } from "@tanstack/react-router";
5 |
6 | const accountQueryOptions = (token: string) =>
7 | queryOptions({
8 | queryFn: async () => {
9 | const { data, error } = await getOrCreateZygAccount(token);
10 | if (error) throw new Error("failed to authenticate user account.");
11 | return data;
12 | },
13 | queryKey: ["account", token],
14 | });
15 |
16 | export const Route = createFileRoute("/_account")({
17 | beforeLoad: async ({ context }) => {
18 | const { supabaseClient } = context;
19 | const { data, error } = await supabaseClient.auth.getSession();
20 | if (error || !data?.session) {
21 | throw redirect({ to: "/signin" });
22 | }
23 |
24 | const token = data.session.access_token as string;
25 | return { token };
26 | },
27 | component: AuthLayout,
28 | loader: async ({ context: { queryClient, supabaseClient } }) => {
29 | const { data, error } = await supabaseClient.auth.getSession();
30 | if (error || !data?.session) throw redirect({ to: "/signin" });
31 | const token = data.session.access_token;
32 | return queryClient.ensureQueryData(accountQueryOptions(token));
33 | },
34 | });
35 |
36 | function AuthLayout() {
37 | const { token } = Route.useRouteContext();
38 | const initialData = Route.useLoaderData();
39 |
40 | const response = useQuery({
41 | enabled: !!token,
42 | initialData: initialData,
43 | queryFn: async () => {
44 | const { data, error } = await getOrCreateZygAccount(token);
45 | if (error) throw new Error("failed to authenticate user account.");
46 | return data;
47 | },
48 | queryKey: ["account", token],
49 | staleTime: 1000 * 60,
50 | });
51 |
52 | const { data } = response;
53 |
54 | return (
55 |
71 |
72 |
73 | );
74 | }
75 |
--------------------------------------------------------------------------------
/frontend/src/routes/_account/workspaces/$workspaceId/settings/route.tsx:
--------------------------------------------------------------------------------
1 | import { buttonVariants } from "@/components/ui/button";
2 | import SideNavLinks from "@/components/workspace/settings/sidenav-links";
3 | import SideNavMobileLinks from "@/components/workspace/settings/sidenav-mobile-links";
4 | import { defaultSortKey } from "@/db/store";
5 | import { cn } from "@/lib/utils";
6 | import { useAccountStore } from "@/providers";
7 | import { ArrowLeftIcon } from "@radix-ui/react-icons";
8 | import { createFileRoute, Link, Outlet } from "@tanstack/react-router";
9 | import * as React from "react";
10 | import { useStore } from "zustand";
11 |
12 | export const Route = createFileRoute(
13 | "/_account/workspaces/$workspaceId/settings"
14 | )({
15 | component: SettingsLayout,
16 | });
17 |
18 | function SettingsLayout() {
19 | const { workspaceId } = Route.useParams();
20 | const accountStore = useAccountStore();
21 | const accountId = useStore(accountStore, (state) =>
22 | state.getAccountId(state)
23 | );
24 | const accountName = useStore(accountStore, (state) => state.getName(state));
25 |
26 | return (
27 |
28 |
29 |
30 |
31 |
37 |
38 | Settings
39 |
40 |
41 |
46 |
47 |
48 |
60 |
61 | );
62 | }
63 |
--------------------------------------------------------------------------------
/backend/adapters/xhandler/handler.go:
--------------------------------------------------------------------------------
1 | package xhandler
2 |
3 | import (
4 | "net/http"
5 | "time"
6 |
7 | "github.com/rs/cors"
8 | "github.com/zyghq/zyg/ports"
9 | )
10 |
11 | type CustomerHandler struct {
12 | ws ports.WorkspaceServicer
13 | cs ports.CustomerServicer
14 | ths ports.ThreadServicer
15 | }
16 |
17 | func NewCustomerHandler(
18 | ws ports.WorkspaceServicer,
19 | cs ports.CustomerServicer,
20 | ths ports.ThreadServicer,
21 | ) *CustomerHandler {
22 | return &CustomerHandler{
23 | ws: ws,
24 | cs: cs,
25 | ths: ths,
26 | }
27 | }
28 |
29 | // handleGetIndex returns the API index.
30 | func handleGetIndex(w http.ResponseWriter, _ *http.Request) {
31 | tm := time.Now().UTC().Format(time.RFC1123)
32 | w.Header().Set("x-datetime", tm)
33 | w.WriteHeader(http.StatusOK)
34 | _, err := w.Write([]byte("ok"))
35 | if err != nil {
36 | return
37 | }
38 | }
39 |
40 | func NewServer(
41 | authService ports.CustomerAuthServicer,
42 | workspaceService ports.WorkspaceServicer,
43 | customerService ports.CustomerServicer,
44 | threadService ports.ThreadServicer,
45 | ) http.Handler {
46 | // init new server mux
47 | mux := http.NewServeMux()
48 | // init handlers
49 | ch := NewCustomerHandler(workspaceService, customerService, threadService)
50 |
51 | mux.HandleFunc("GET /{$}", handleGetIndex)
52 |
53 | mux.HandleFunc("GET /mail/kyc/{$}", ch.handleMailRedirectKyc)
54 |
55 | mux.HandleFunc("GET /widgets/{widgetId}/config/{$}", ch.handleGetWidgetConfig)
56 | mux.HandleFunc("POST /widgets/{widgetId}/init/{$}", ch.handleInitWidget)
57 |
58 | mux.Handle("GET /widgets/{widgetId}/me/{$}",
59 | NewEnsureAuth(ch.handleGetCustomer, authService))
60 |
61 | // Creates a new chat thread.
62 | mux.Handle("POST /widgets/{widgetId}/threads/chat/{$}",
63 | NewEnsureAuth(ch.handleCreateThreadChat, authService))
64 | // Returns a list of chat threads.
65 | mux.Handle("GET /widgets/{widgetId}/threads/chat/{$}",
66 | NewEnsureAuth(ch.handleGetCustomerThreadChats, authService))
67 | // Creates a new thread chat message.
68 | mux.Handle("POST /widgets/{widgetId}/threads/chat/{threadId}/messages/{$}",
69 | NewEnsureAuth(ch.handleCreateThreadChatMessage, authService))
70 | // Returns a list of thread chat messages.
71 | mux.Handle("GET /widgets/{widgetId}/threads/chat/{threadId}/messages/{$}",
72 | NewEnsureAuth(ch.handleGetThreadChatMessages, authService))
73 |
74 | c := cors.New(cors.Options{
75 | AllowedOrigins: []string{"*"},
76 | AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH", "HEAD"},
77 | AllowedHeaders: []string{"*"},
78 | })
79 |
80 | handler := LoggingMiddleware(c.Handler(mux))
81 |
82 | return handler
83 | }
84 |
--------------------------------------------------------------------------------
/templates/emails/kyc.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Body,
3 | Button,
4 | Container,
5 | Head,
6 | Html,
7 | Img,
8 | Preview,
9 | Section,
10 | Text,
11 | } from "@react-email/components";
12 | import * as React from "react";
13 |
14 | export const KycEmail = () => (
15 |
16 |
17 | {`{{ .PreviewText }}`}
18 |
19 |
20 |
21 |
28 |
29 |
30 | You started a conversation.
31 |
32 |
33 | {`{{ .PreviewText }}`}
34 |
37 |
38 |
39 |
40 | ❤️ Zyg ・ Open source, made with love around the world ❤️
41 |
42 |
43 |
44 |
45 | );
46 |
47 | export default KycEmail;
48 |
49 | const main = {
50 | backgroundColor: "#ffffff",
51 | color: "#24292e",
52 | fontFamily:
53 | '-apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji"',
54 | };
55 |
56 | const container = {
57 | maxWidth: "480px",
58 | margin: "0 auto",
59 | padding: "20px 0 48px",
60 | };
61 |
62 | const logoImage = {
63 | marginTop: "0px",
64 | marginBottom: "0px",
65 | marginLeft: "auto",
66 | marginRight: "auto",
67 | };
68 |
69 | // const heading = {
70 | // marginTop: "12px",
71 | // marginBottom: "12px",
72 | // color: "#6a737d",
73 | // fontSize: "16px",
74 | // };
75 |
76 | const title = {
77 | fontSize: "16px",
78 | lineHeight: 1.25,
79 | };
80 |
81 | const section = {
82 | padding: "24px",
83 | border: "solid 1px #dedede",
84 | borderRadius: "5px",
85 | textAlign: "center" as const,
86 | };
87 |
88 | const text = {
89 | margin: "0 0 10px 0",
90 | textAlign: "left" as const,
91 | };
92 |
93 | const button = {
94 | fontWeight: "600",
95 | fontSize: "14px",
96 | backgroundColor: "#000000",
97 | color: "#fff",
98 | lineHeight: 1.5,
99 | borderRadius: "0.5em",
100 | padding: "10px 10px",
101 | };
102 |
103 | const footer = {
104 | color: "#6a737d",
105 | fontSize: "12px",
106 | textAlign: "center" as const,
107 | marginTop: "40px",
108 | };
109 |
--------------------------------------------------------------------------------
/frontend/src/routes/(auth)/signout.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@/components/ui/button";
2 | import {
3 | Card,
4 | CardContent,
5 | CardFooter,
6 | CardHeader,
7 | CardTitle,
8 | } from "@/components/ui/card";
9 | import { ArrowLeftIcon } from "@radix-ui/react-icons";
10 | import {
11 | createFileRoute,
12 | Link,
13 | redirect,
14 | useRouter,
15 | useRouterState,
16 | } from "@tanstack/react-router";
17 | import React from "react";
18 |
19 | export const Route = createFileRoute("/(auth)/signout")({
20 | beforeLoad: async ({ context }) => {
21 | const { supabaseClient } = context;
22 | const { data, error } = await supabaseClient.auth.getSession();
23 | const isAuthenticated = !error && data?.session;
24 | if (!isAuthenticated) {
25 | throw redirect({ to: "/signin" });
26 | }
27 | },
28 | component: SignOutComponent,
29 | });
30 |
31 | function SignOutComponent() {
32 | const { supabaseClient } = Route.useRouteContext();
33 | const router = useRouter();
34 | const navigate = Route.useNavigate();
35 | const isLoading = useRouterState({ select: (s) => s.isLoading });
36 | const [isError, setIsError] = React.useState(false);
37 |
38 | async function confirmSignOut() {
39 | const { error } = await supabaseClient.auth.signOut();
40 | if (error) {
41 | setIsError(true);
42 | return;
43 | }
44 | await router.invalidate();
45 | await navigate({ to: "/signin" });
46 | }
47 |
48 | return (
49 |
50 |
51 |
52 | Sign out
53 |
54 |
55 |
56 | Are you sure you want to sign out?
57 |
58 |
59 |
60 |
66 |
73 |
74 | {isError && (
75 |
76 |
77 | Something went wrong. Please try again later.
78 |
79 |
80 | )}
81 |
82 |
83 | );
84 | }
85 |
--------------------------------------------------------------------------------
/backend/models/settings.go:
--------------------------------------------------------------------------------
1 | package models
2 |
3 | import (
4 | "encoding/json"
5 | "time"
6 | )
7 |
8 | const (
9 | DKIMUpdateStatusVerified = "Verified" // Depends on Postmark
10 | )
11 |
12 | type PostmarkMailServerSetting struct {
13 | WorkspaceId string `json:"workspaceId"`
14 | ServerId int64 `json:"serverId"`
15 | ServerToken string `json:"serverToken"`
16 | IsEnabled bool `json:"isEnabled"`
17 | Email string `json:"email"`
18 | Domain string `json:"domain"`
19 | HasError bool `json:"hasError"`
20 | InboundEmail *string `json:"inboundEmail"` // After creating the server
21 | HasForwardingEnabled bool `json:"hasForwardingEnabled"`
22 | HasDNS bool `json:"hasDNS"`
23 | IsDNSVerified bool `json:"isDNSVerified"`
24 | DNSVerifiedAt *time.Time `json:"dnsVerifiedAt"` // After DNS is verified
25 | DNSDomainId *int64 `json:"dnsDomainId"` // After adding domain
26 | DKIMHost *string `json:"dkimHost"` // After adding domain - tracks latest
27 | DKIMTextValue *string `json:"dkimTextValue"` // After adding domain - tracks latest
28 | DKIMUpdateStatus *string `json:"dkimUpdateStatus"` // After adding domain
29 | ReturnPathDomain *string `json:"returnPathDomain"` // After adding domain
30 | ReturnPathDomainCNAME *string `json:"returnPathDomainCNAME"` // After adding domain
31 | ReturnPathDomainVerified bool `json:"returnPathDomainVerified"` // After adding domain
32 | CreatedAt time.Time `json:"createdAt"`
33 | UpdatedAt time.Time `json:"updatedAt"`
34 | }
35 |
36 | func (pm PostmarkMailServerSetting) MarshalJSON() ([]byte, error) {
37 | type Aux PostmarkMailServerSetting
38 | var token string
39 | maskLeft := func(s string) string {
40 | rs := []rune(s)
41 | for i := range rs[:len(rs)-4] {
42 | rs[i] = '*'
43 | }
44 | return string(rs)
45 | }
46 |
47 | token = maskLeft(pm.ServerToken)
48 | aux := &struct {
49 | Aux
50 | ServerToken string `json:"serverToken"`
51 | }{
52 | Aux: Aux(pm),
53 | ServerToken: token,
54 | }
55 | return json.Marshal(aux)
56 | }
57 |
58 | // DNSHasVerified checks if the DKIM update status is "Verified" and the return path domain is verified
59 | // returning true or false.
60 | func (pm PostmarkMailServerSetting) DNSHasVerified() bool {
61 | if pm.DKIMUpdateStatus != nil {
62 | return *pm.DKIMUpdateStatus == DKIMUpdateStatusVerified && pm.ReturnPathDomainVerified
63 | }
64 | return false
65 | }
66 |
--------------------------------------------------------------------------------
/frontend/src/main.tsx:
--------------------------------------------------------------------------------
1 | import { ThemeProvider } from "@/providers";
2 | import * as Sentry from "@sentry/react";
3 | import { createClient } from "@supabase/supabase-js";
4 | import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
5 | import { createRouter, RouterProvider } from "@tanstack/react-router";
6 | import ReactDOM from "react-dom/client";
7 |
8 | import "./globals.css";
9 |
10 | Sentry.init({
11 | dsn: "https://520d1dc4721849c3c4a8cd548896b039@o4508454426181632.ingest.us.sentry.io/4508454430441472",
12 | enabled: import.meta.env.VITE_SENTRY_ENABLED === "1" || false,
13 | environment: import.meta.env.VITE_SENTRY_ENV || "staging",
14 | integrations: [
15 | Sentry.browserTracingIntegration(),
16 | Sentry.replayIntegration(),
17 | Sentry.feedbackIntegration({
18 | // Additional SDK configuration goes in here, for example:
19 | colorScheme: "system",
20 | }),
21 | ],
22 | replaysOnErrorSampleRate: 1.0, // If you're not already sampling the entire session, change the sample rate to 100% when sampling sessions where errors occur.
23 | // Session Replay
24 | replaysSessionSampleRate: 0.1, // This sets the sample rate at 10%. You may want to change it to 100% while in development and then sample at a lower rate in production.
25 | // Set 'tracePropagationTargets' to control for which URLs distributed tracing should be enabled
26 | tracePropagationTargets: ["localhost", /^https:\/\/.*zyg\.ai/],
27 | // Tracing
28 | tracesSampleRate: 1.0, // Capture 100% of the transactions
29 | });
30 |
31 | const supabaseClient = createClient(
32 | import.meta.env.VITE_SUPABASE_URL,
33 | import.meta.env.VITE_SUPABASE_ANON_KEY,
34 | );
35 |
36 | // Import the generated route tree
37 | import { routeTree } from "./routeTree.gen";
38 |
39 | const queryClient = new QueryClient();
40 |
41 | // Set up a Router instance
42 | const router = createRouter({
43 | context: {
44 | queryClient,
45 | supabaseClient,
46 | },
47 | defaultPreload: "intent",
48 | // Since we're using React Query, we don't want loader calls to ever be stale
49 | // This will ensure that the loader is always called when the route is preloaded or visited
50 | defaultPreloadStaleTime: 0,
51 | routeTree,
52 | });
53 |
54 | // Register the router instance for type safety
55 | declare module "@tanstack/react-router" {
56 | interface Register {
57 | router: typeof router;
58 | }
59 | }
60 |
61 | // Render the app
62 | const rootElement = document.getElementById("app")!;
63 | if (!rootElement.innerHTML) {
64 | const root = ReactDOM.createRoot(rootElement);
65 | root.render(
66 |
67 |
68 |
69 |
70 | ,
71 | );
72 | }
73 |
--------------------------------------------------------------------------------
/frontend/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from "tailwindcss";
2 |
3 | export default {
4 | content: [
5 | "./pages/**/*.{ts,tsx}",
6 | "./components/**/*.{ts,tsx}",
7 | "./app/**/*.{ts,tsx}",
8 | "./src/**/*.{ts,tsx}",
9 | ],
10 | darkMode: ["class"],
11 | plugins: [require("tailwindcss-animate")],
12 | prefix: "",
13 | theme: {
14 | container: {
15 | center: true,
16 | padding: "2rem",
17 | screens: {
18 | "2xl": "1400px",
19 | },
20 | },
21 | extend: {
22 | animation: {
23 | "accordion-down": "accordion-down 0.2s ease-out",
24 | "accordion-up": "accordion-up 0.2s ease-out",
25 | },
26 | borderRadius: {
27 | lg: "var(--radius)",
28 | md: "calc(var(--radius) - 2px)",
29 | sm: "calc(var(--radius) - 4px)",
30 | },
31 | colors: {
32 | accent: {
33 | DEFAULT: "hsl(var(--accent))",
34 | foreground: "hsl(var(--accent-foreground))",
35 | },
36 | background: "hsl(var(--background))",
37 | border: "hsl(var(--border))",
38 | card: {
39 | DEFAULT: "hsl(var(--card))",
40 | foreground: "hsl(var(--card-foreground))",
41 | },
42 | destructive: {
43 | DEFAULT: "hsl(var(--destructive))",
44 | foreground: "hsl(var(--destructive-foreground))",
45 | },
46 | foreground: "hsl(var(--foreground))",
47 | input: "hsl(var(--input))",
48 | muted: {
49 | DEFAULT: "hsl(var(--muted))",
50 | foreground: "hsl(var(--muted-foreground))",
51 | },
52 | popover: {
53 | DEFAULT: "hsl(var(--popover))",
54 | foreground: "hsl(var(--popover-foreground))",
55 | },
56 | primary: {
57 | DEFAULT: "hsl(var(--primary))",
58 | foreground: "hsl(var(--primary-foreground))",
59 | },
60 | ring: "hsl(var(--ring))",
61 | secondary: {
62 | DEFAULT: "hsl(var(--secondary))",
63 | foreground: "hsl(var(--secondary-foreground))",
64 | },
65 | },
66 | gridTemplateColumns: {
67 | "custom-thread-list-default": "auto 1fr auto",
68 | "custom-thread-list-xl": "18px 180px 1fr 220px",
69 | },
70 | gridTemplateRows: {
71 | "custom-thread-list-default": "auto auto",
72 | "custom-thread-list-xl": "1fr",
73 | },
74 | keyframes: {
75 | "accordion-down": {
76 | from: { height: "0" },
77 | to: { height: "var(--radix-accordion-content-height)" },
78 | },
79 | "accordion-up": {
80 | from: { height: "var(--radix-accordion-content-height)" },
81 | to: { height: "0" },
82 | },
83 | },
84 | },
85 | },
86 | } satisfies Config;
87 |
--------------------------------------------------------------------------------
/frontend/src/components/workspace/insights/overview.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | ChartConfig,
3 | ChartContainer,
4 | ChartTooltip,
5 | ChartTooltipContent,
6 | } from "@/components/ui/chart";
7 | import { Area, AreaChart, Bar, BarChart, CartesianGrid, XAxis } from "recharts";
8 |
9 | export const description = "A snapshot of the number of threads in Todo.";
10 |
11 | const chartData = [
12 | { desktop: 186, month: "January" },
13 | { desktop: 305, month: "February" },
14 | { desktop: 237, month: "March" },
15 | { desktop: 73, month: "April" },
16 | { desktop: 209, month: "May" },
17 | { desktop: 214, month: "June" },
18 | ];
19 |
20 | const chartConfig = {
21 | desktop: {
22 | color: "hsl(var(--chart-1))",
23 | label: "Desktop",
24 | },
25 | } satisfies ChartConfig;
26 |
27 | export function QueueSize({ className }: { className?: string }) {
28 | return (
29 |
30 |
38 |
39 | value.slice(0, 3)}
43 | tickLine={false}
44 | tickMargin={8}
45 | />
46 | }
48 | cursor={false}
49 | />
50 |
57 |
58 |
59 | );
60 | }
61 |
62 | const volumeData = [
63 | { desktop: 186, month: "January" },
64 | { desktop: 305, month: "February" },
65 | { desktop: 237, month: "March" },
66 | { desktop: 73, month: "April" },
67 | { desktop: 209, month: "May" },
68 | { desktop: 214, month: "June" },
69 | ];
70 |
71 | export function Volume({ className }: { className?: string }) {
72 | return (
73 |
74 |
75 |
76 | value.slice(0, 3)}
80 | tickLine={false}
81 | tickMargin={10}
82 | />
83 | }
85 | cursor={false}
86 | />
87 |
88 |
89 |
90 | );
91 | }
92 |
--------------------------------------------------------------------------------
/backend/models/customer.go:
--------------------------------------------------------------------------------
1 | package models
2 |
3 | import (
4 | "crypto/sha256"
5 | "database/sql"
6 | "encoding/base64"
7 | "fmt"
8 | "github.com/rs/xid"
9 | "github.com/sanchitrk/namingo"
10 | "github.com/zyghq/zyg"
11 | "strings"
12 | "time"
13 | )
14 |
15 | type Customer struct {
16 | WorkspaceId string
17 | CustomerId string
18 | ExternalId sql.NullString
19 | Email sql.NullString
20 | Phone sql.NullString
21 | Name string
22 | IsEmailVerified bool
23 | Role string
24 | UpdatedAt time.Time
25 | CreatedAt time.Time
26 | }
27 |
28 | func (c Customer) GenId() string {
29 | return "cs" + xid.New().String()
30 | }
31 |
32 | func (c Customer) Visitor() string {
33 | return "visitor"
34 | }
35 |
36 | func (c Customer) Lead() string {
37 | return "lead"
38 | }
39 |
40 | func (c Customer) Engaged() string {
41 | return "engaged"
42 | }
43 |
44 | func (c Customer) IsVisitor() bool {
45 | return c.Role == c.Visitor()
46 | }
47 |
48 | func (c Customer) AnonName() string {
49 | return namingo.Generate(2, " ", namingo.TitleCase())
50 | }
51 |
52 | func (c Customer) AvatarUrl() string {
53 | url := zyg.GetAvatarBaseURL()
54 | // url may or may not have a trailing slash
55 | // add a trailing slash if it doesn't have one
56 | if !strings.HasSuffix(url, "/") {
57 | url = url + "/"
58 | }
59 | return url + c.CustomerId
60 | }
61 |
62 | func (c Customer) AsCustomerActor() CustomerActor {
63 | return CustomerActor{
64 | CustomerId: c.CustomerId,
65 | Name: c.Name,
66 | }
67 | }
68 |
69 | // IdentityHash is a hash of the customer's identity
70 | // Combined these fields create a unique hash for the customer
71 | // (XXX): You might have to update this if you plan to add more identity fields
72 | func (c Customer) IdentityHash() string {
73 | h := sha256.New()
74 | // Combine all fields into a single string
75 | identityString := fmt.Sprintf("%s:%s:%s:%s:%s:%t",
76 | c.WorkspaceId,
77 | c.CustomerId,
78 | c.ExternalId.String,
79 | c.Email.String,
80 | c.Phone.String,
81 | c.IsEmailVerified,
82 | )
83 |
84 | // Write the combined string to the hash
85 | h.Write([]byte(identityString))
86 |
87 | // Return the hash as a base64 encoded string
88 | return base64.StdEncoding.EncodeToString(h.Sum(nil))
89 | }
90 |
91 | func (c Customer) MakeCopy() Customer {
92 | return Customer{
93 | WorkspaceId: c.WorkspaceId,
94 | CustomerId: c.CustomerId,
95 | ExternalId: c.ExternalId,
96 | Email: c.Email,
97 | Phone: c.Phone,
98 | Name: c.Name,
99 | IsEmailVerified: c.IsEmailVerified,
100 | Role: c.Role,
101 | CreatedAt: c.CreatedAt,
102 | UpdatedAt: c.UpdatedAt,
103 | }
104 | }
105 |
--------------------------------------------------------------------------------
/frontend/src/routes/_account/workspaces/$workspaceId/settings/ai.tsx:
--------------------------------------------------------------------------------
1 | import { Separator } from "@/components/ui/separator";
2 | import { Switch } from "@/components/ui/switch";
3 | import { MagicWandIcon } from "@radix-ui/react-icons";
4 | import { createFileRoute } from "@tanstack/react-router";
5 | import { FlaskConicalIcon } from "lucide-react";
6 |
7 | export const Route = createFileRoute(
8 | "/_account/workspaces/$workspaceId/settings/ai"
9 | )({
10 | component: AISettings,
11 | });
12 |
13 | function AISettings() {
14 | return (
15 |
16 |
17 |
23 |
24 |
25 |
AI Workflows
26 |
27 |
28 |
Experimental
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
Auto Labelling
37 |
38 | {`New threads will automatically be tagged with labels.
39 | Threads with labels added via the API won't be affected`}
40 |
41 |
42 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
Thread Summarisation
55 |
56 | {`We'll summarise your conversations`}
57 |
58 |
59 |
64 |
65 |
66 |
67 |
68 |
69 |
70 | );
71 | }
72 |
--------------------------------------------------------------------------------
/frontend/src/lib/search-params.ts:
--------------------------------------------------------------------------------
1 | import { sortKeys, todoThreadStages } from "@/db/helpers";
2 | import { defaultSortKey } from "@/db/store";
3 | import { fallback } from "@tanstack/router-zod-adapter";
4 | import { z } from "zod";
5 | //
6 | // for more: https://tanstack.com/router/latest/docs/framework/react/guide/search-params
7 | // usage of `.catch` or `default` matters.
8 | const stagesSchema = (validValues: string[]) => {
9 | const sanitizeArray = (arr: string[]) => {
10 | // remove duplicates
11 | const uniqueValues = [...new Set(arr)];
12 | // filter only valid values
13 | const uniqueValidValues: string[] = uniqueValues.filter((val) =>
14 | validValues.includes(val)
15 | );
16 |
17 | // no valid values
18 | if (uniqueValidValues.length === 0) {
19 | throw new Error("invalid statuses passed");
20 | }
21 |
22 | if (uniqueValidValues.length === 1) {
23 | return uniqueValidValues[0];
24 | }
25 |
26 | return uniqueValidValues;
27 | };
28 | return z.union([
29 | z.string().refine((value) => validValues.includes(value)),
30 | z.array(z.string()).transform(sanitizeArray),
31 | z.undefined(),
32 | ]);
33 | };
34 |
35 | const prioritiesSchema = (validValues: string[]) => {
36 | const sanitizeArray = (arr: string[]) => {
37 | // remove duplicates
38 | const uniqueValues = [...new Set(arr)];
39 | // filter only valid values
40 | const uniqueValidValues: string[] = uniqueValues.filter((val) =>
41 | validValues.includes(val)
42 | );
43 |
44 | // no valid values
45 | if (uniqueValidValues.length === 0) {
46 | throw new Error("invalid priorities passed");
47 | }
48 |
49 | if (uniqueValidValues.length === 1) {
50 | return uniqueValidValues[0];
51 | }
52 |
53 | return uniqueValidValues;
54 | };
55 | return z.union([
56 | z.string().refine((value) => validValues.includes(value)),
57 | z.array(z.string()).transform(sanitizeArray),
58 | z.undefined(),
59 | ]);
60 | };
61 |
62 | const assigneesScheme = z.union([
63 | z.string(),
64 | z.array(z.string()),
65 | z.undefined(),
66 | ]);
67 |
68 | // const sortEnum = z.enum(["last-message-dsc", "created-asc", "created-dsc"]);
69 | // const sortSchema = z.union([sortEnum, z.undefined()]);
70 |
71 | // using fallback to avoid the unknown
72 | // see https://tanstack.com/router/latest/docs/framework/react/guide/search-params#:~:text=However%20the%20use%20of%20catch%20here%20overrides%20the%20types%20and%20makes%20page%2C%20filter%20and%20sort%20unknown%20causing%20type%20loss.%20We%20have%20handled
73 | export const threadSearchSchema = z.object({
74 | assignees: fallback(assigneesScheme, undefined).catch(undefined),
75 | priorities: fallback(
76 | prioritiesSchema(["urgent", "high", "normal", "low"]),
77 | undefined
78 | ).catch(undefined),
79 | sort: z.enum([...sortKeys]).catch(defaultSortKey),
80 | stages: fallback(stagesSchema([...todoThreadStages]), undefined).catch(
81 | undefined
82 | ),
83 | });
84 |
85 | export type ThreadSearch = z.infer;
86 |
--------------------------------------------------------------------------------
/frontend/src/components/workspace/thread-list-item.tsx:
--------------------------------------------------------------------------------
1 | import { stageIcon } from "@/components/icons";
2 | import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
3 | // import { Badge } from "@/components/ui/badge";
4 | // import { threadStatusVerboseName } from "@/db/helpers";
5 | import { channelIcon } from "@/components/icons";
6 | import { Thread } from "@/db/models";
7 | import { useWorkspaceStore } from "@/providers";
8 | import { PersonIcon } from "@radix-ui/react-icons";
9 | import { Link } from "@tanstack/react-router";
10 | import { formatDistanceToNow } from "date-fns";
11 | import { useStore } from "zustand";
12 |
13 | export function ThreadLinkItem({
14 | thread,
15 | workspaceId,
16 | }: {
17 | thread: Thread;
18 | workspaceId: string;
19 | }) {
20 | const workspaceStore = useWorkspaceStore();
21 | const customerName = useStore(workspaceStore, (state) =>
22 | state.viewCustomerName(state, thread.customerId),
23 | );
24 | return (
25 |
30 |
31 | {stageIcon(thread.stage, {
32 | className: "w-4 h-4 text-indigo-500 dark:text-accent-foreground",
33 | })}
34 |
35 |
36 |
37 |
{customerName}
38 |
39 |
40 |
41 |
42 |
43 |
44 | {channelIcon(thread.channel, {
45 | className: "h-4 w-4 text-muted-foreground",
46 | })}
47 |
48 |
49 | {formatDistanceToNow(new Date(thread.createdAt), {
50 | addSuffix: true,
51 | })}
52 |
53 | {thread.assigneeId ? (
54 |
55 |
59 | M
60 |
61 | ) : (
62 |
63 | )}
64 |
65 |
66 |
67 |
68 |
69 | {thread.title}
70 |
71 |
72 | {thread.previewText}
73 |
74 |
75 |
76 |
77 |
78 | );
79 | }
80 |
--------------------------------------------------------------------------------
/frontend/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "frontend",
3 | "private": true,
4 | "version": "0.0.0",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "vite",
8 | "build": "tsc && vite build",
9 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
10 | "preview": "vite preview"
11 | },
12 | "dependencies": {
13 | "@hookform/resolvers": "^3.9.0",
14 | "@radix-ui/react-alert-dialog": "^1.1.1",
15 | "@radix-ui/react-avatar": "^1.1.0",
16 | "@radix-ui/react-checkbox": "^1.1.3",
17 | "@radix-ui/react-collapsible": "^1.1.0",
18 | "@radix-ui/react-dialog": "^1.1.1",
19 | "@radix-ui/react-dropdown-menu": "^2.1.1",
20 | "@radix-ui/react-icons": "^1.3.0",
21 | "@radix-ui/react-label": "^2.1.0",
22 | "@radix-ui/react-popover": "^1.1.1",
23 | "@radix-ui/react-radio-group": "^1.2.0",
24 | "@radix-ui/react-scroll-area": "^1.1.0",
25 | "@radix-ui/react-select": "^2.1.1",
26 | "@radix-ui/react-separator": "^1.1.0",
27 | "@radix-ui/react-slot": "^1.1.0",
28 | "@radix-ui/react-switch": "^1.1.0",
29 | "@radix-ui/react-tabs": "^1.1.0",
30 | "@radix-ui/react-toast": "^1.2.1",
31 | "@radix-ui/react-tooltip": "^1.1.4",
32 | "@sentry/react": "^8.44.0",
33 | "@sentry/vite-plugin": "^2.22.7",
34 | "@supabase/auth-ui-react": "^0.4.7",
35 | "@supabase/auth-ui-shared": "^0.1.8",
36 | "@supabase/supabase-js": "^2.45.4",
37 | "@tanstack/react-query": "^5.55.4",
38 | "@tanstack/react-query-devtools": "^5.55.4",
39 | "@tanstack/react-router": "^1.57.9",
40 | "@tanstack/react-virtual": "^3.10.7",
41 | "@tanstack/router-zod-adapter": "^1.58.3",
42 | "@tiptap/extension-character-count": "^2.11.0",
43 | "@tiptap/extension-link": "^2.10.4",
44 | "@tiptap/extension-placeholder": "^2.10.4",
45 | "@tiptap/react": "^2.10.4",
46 | "@tiptap/starter-kit": "^2.10.4",
47 | "@types/lodash": "^4.17.7",
48 | "@uidotdev/usehooks": "^2.4.1",
49 | "class-variance-authority": "^0.7.0",
50 | "clsx": "^2.1.1",
51 | "cmdk": "^1.0.0",
52 | "date-fns": "^3.6.0",
53 | "immer": "^10.1.1",
54 | "jotai": "^2.9.3",
55 | "lodash": "^4.17.21",
56 | "lucide-react": "^0.436.0",
57 | "motion": "^11.15.0",
58 | "react": "^18.3.1",
59 | "react-dom": "^18.3.1",
60 | "react-hook-form": "^7.53.0",
61 | "react-markdown": "^9.0.1",
62 | "react-resizable-panels": "^2.1.2",
63 | "react-virtuoso": "^4.10.4",
64 | "recharts": "^2.12.7",
65 | "tailwind-merge": "^2.5.2",
66 | "tailwindcss-animate": "^1.0.7",
67 | "zod": "^3.23.8",
68 | "zustand": "^4.5.5"
69 | },
70 | "devDependencies": {
71 | "@tanstack/eslint-plugin-query": "^5.53.0",
72 | "@tanstack/router-devtools": "^1.57.9",
73 | "@tanstack/router-vite-plugin": "^1.57.9",
74 | "@types/node": "^20.16.5",
75 | "@types/react": "^18.3.5",
76 | "@types/react-dom": "^18.3.0",
77 | "@typescript-eslint/eslint-plugin": "^7.18.0",
78 | "@typescript-eslint/parser": "^7.18.0",
79 | "@vitejs/plugin-react-swc": "^3.7.0",
80 | "autoprefixer": "^10.4.20",
81 | "eslint": "^8.57.0",
82 | "eslint-plugin-perfectionist": "^3.6.0",
83 | "eslint-plugin-react-hooks": "^4.6.2",
84 | "eslint-plugin-react-refresh": "^0.4.11",
85 | "postcss": "^8.4.45",
86 | "prettier": "^3.3.3",
87 | "prettier-plugin-tailwindcss": "^0.6.8",
88 | "tailwindcss": "^3.4.10",
89 | "typescript": "^5.6.2",
90 | "vite": "^5.4.4"
91 | }
92 | }
93 |
--------------------------------------------------------------------------------
/frontend/src/providers.tsx:
--------------------------------------------------------------------------------
1 | import { buildAccountStore, buildStore } from "@/db/store";
2 | import React from "react";
3 | import { StoreApi } from "zustand";
4 |
5 | const createZustandContext = >(
6 | getStore: (initial: TInitial) => TStore,
7 | ) => {
8 | const Context = React.createContext(null as any as TStore);
9 |
10 | const Provider = (props: {
11 | children?: React.ReactNode;
12 | initialValue: TInitial;
13 | }) => {
14 | const [store] = React.useState(() => getStore(props.initialValue));
15 |
16 | return {props.children};
17 | };
18 |
19 | return {
20 | Context,
21 | Provider,
22 | useContext: () => React.useContext(Context),
23 | };
24 | };
25 |
26 | const workspaceStore = createZustandContext(buildStore);
27 | export const WorkspaceStoreContext = workspaceStore.Context;
28 | export const WorkspaceStoreProvider = workspaceStore.Provider;
29 |
30 | export function useWorkspaceStore() {
31 | const context = React.useContext(WorkspaceStoreContext);
32 | if (!context) {
33 | throw new Error(
34 | "useWorkspaceStore must be used within a WorkspaceStoreProvider",
35 | );
36 | }
37 | return context;
38 | }
39 |
40 | const accountStore = createZustandContext(buildAccountStore);
41 | export const AccountStoreContext = accountStore.Context;
42 | export const AccountStoreProvider = accountStore.Provider;
43 |
44 | export function useAccountStore() {
45 | const context = React.useContext(AccountStoreContext);
46 | if (!context) {
47 | throw new Error(
48 | "useAccountStore must be used within an AccountStoreProvider",
49 | );
50 | }
51 | return context;
52 | }
53 |
54 | // theme provider from shadcn/ui
55 | type Theme = "dark" | "light" | "system";
56 |
57 | type ThemeProviderProps = {
58 | children: React.ReactNode;
59 | defaultTheme?: Theme;
60 | storageKey?: string;
61 | };
62 |
63 | type ThemeProviderState = {
64 | setTheme: (theme: Theme) => void;
65 | theme: Theme;
66 | };
67 |
68 | const initialState: ThemeProviderState = {
69 | setTheme: () => null,
70 | theme: "system",
71 | };
72 |
73 | export const ThemeProviderContext =
74 | React.createContext(initialState);
75 |
76 | export function ThemeProvider({
77 | children,
78 | defaultTheme = "system",
79 | storageKey = "vite-ui-theme",
80 | ...props
81 | }: ThemeProviderProps) {
82 | const [theme, setTheme] = React.useState(
83 | () => (localStorage.getItem(storageKey) as Theme) || defaultTheme,
84 | );
85 |
86 | React.useEffect(() => {
87 | const root = window.document.documentElement;
88 |
89 | root.classList.remove("light", "dark");
90 |
91 | if (theme === "system") {
92 | const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
93 | .matches
94 | ? "dark"
95 | : "light";
96 |
97 | root.classList.add(systemTheme);
98 | return;
99 | }
100 |
101 | root.classList.add(theme);
102 | }, [theme]);
103 |
104 | const value = {
105 | setTheme: (theme: Theme) => {
106 | localStorage.setItem(storageKey, theme);
107 | setTheme(theme);
108 | },
109 | theme,
110 | };
111 |
112 | return (
113 |
114 | {children}
115 |
116 | );
117 | }
118 |
--------------------------------------------------------------------------------
/backend/adapters/handler/auth.go:
--------------------------------------------------------------------------------
1 | package handler
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "fmt"
7 | "log/slog"
8 | "net/http"
9 | "strings"
10 |
11 | "github.com/zyghq/zyg"
12 | "github.com/zyghq/zyg/models"
13 | "github.com/zyghq/zyg/ports"
14 | "github.com/zyghq/zyg/services"
15 | )
16 |
17 | type AuthenticatedAccountHandler func(http.ResponseWriter, *http.Request, *models.Account)
18 |
19 | type AuthenticatedMemberHandler func(http.ResponseWriter, *http.Request, *models.Member)
20 |
21 | func CheckAuthCredentials(r *http.Request) (string, string, error) {
22 | authHeader := r.Header.Get("Authorization")
23 | if authHeader == "" {
24 | return "", "", fmt.Errorf("no authorization header provided")
25 | }
26 | parts := strings.Split(authHeader, " ")
27 | if len(parts) != 2 {
28 | return "", "", fmt.Errorf("invalid token")
29 | }
30 | scheme := strings.ToLower(parts[0])
31 | return scheme, parts[1], nil
32 | }
33 |
34 | func AuthenticateAccount(
35 | ctx context.Context, authz ports.AuthServicer, scheme string, cred string) (models.Account, error) {
36 | if scheme == "token" {
37 | account, err := authz.ValidatePersonalAccessToken(ctx, cred)
38 | if err != nil {
39 | return account, fmt.Errorf("failed to authenticate got error: %v", err)
40 | }
41 | slog.Info("authenticated account with PAT", slog.String("accountId", account.AccountId))
42 | return account, nil
43 | } else if scheme == "bearer" {
44 | hmacSecret, err := zyg.GetEnv("SUPABASE_JWT_SECRET")
45 | if err != nil {
46 | return models.Account{}, fmt.Errorf("failed to get env SUPABASE_JWT_SECRET got error: %v", err)
47 | }
48 | ac, err := services.ParseJWTToken(cred, []byte(hmacSecret))
49 | if err != nil {
50 | return models.Account{}, fmt.Errorf("failed to parse JWT token got error: %v", err)
51 | }
52 | sub, err := ac.RegisteredClaims.GetSubject()
53 | if err != nil {
54 | return models.Account{}, fmt.Errorf("cannot get subject from parsed token: %v", err)
55 | }
56 |
57 | account, err := authz.AuthenticateUserAccount(ctx, sub)
58 | if errors.Is(err, services.ErrAccountNotFound) {
59 | slog.Error("auth account not found", slog.Any("error", err))
60 | return account, fmt.Errorf("account not found or does not exist")
61 | }
62 | if errors.Is(err, services.ErrAccount) {
63 | slog.Error("failed to fetch account by auth user id", slog.Any("error", err))
64 | return account, fmt.Errorf("failed to get account by auth user id: %s got error: %v", sub, err)
65 | }
66 | if err != nil {
67 | slog.Error("something went wrong", slog.Any("error", err))
68 | return account, fmt.Errorf("failed to get account by auth user id: %s got error: %v", sub, err)
69 | }
70 | return account, nil
71 | } else {
72 | return models.Account{}, fmt.Errorf("unsupported scheme `%s` cannot authenticate", scheme)
73 | }
74 | }
75 |
76 | func AuthenticateMember(
77 | ctx context.Context, authz ports.AuthServicer, workspaceId string, scheme string, cred string,
78 | ) (models.Member, error) {
79 | if scheme == "bearer" {
80 | account, err := AuthenticateAccount(ctx, authz, scheme, cred)
81 | if err != nil {
82 | return models.Member{}, err
83 | }
84 |
85 | member, err := authz.AuthenticateWorkspaceMember(ctx, workspaceId, account.AccountId)
86 | if err != nil {
87 | return models.Member{}, fmt.Errorf("failed to authenticate workspace member: %v", err)
88 | }
89 |
90 | return member, nil
91 | } else {
92 | return models.Member{}, fmt.Errorf("unsupported scheme `%s` cannot authenticate", scheme)
93 | }
94 | }
95 |
--------------------------------------------------------------------------------
/frontend/src/components/workspace/settings/email/dns.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@/components/ui/button.tsx";
2 | import { Card, CardContent } from "@/components/ui/card.tsx";
3 | import { Check, Clock, Copy } from "lucide-react";
4 |
5 | interface DNSRecord {
6 | hostname: string;
7 | status: "Pending" | "Verified";
8 | type: string;
9 | value: string;
10 | }
11 |
12 | export function Dns({ records }: { records: DNSRecord[] }) {
13 | return (
14 |
15 |
16 |
17 | {records.map((record, index) => (
18 |
19 |
20 |
21 |
22 | Type
23 |
24 |
{record.type}
25 |
26 |
27 |
28 | Hostname
29 |
30 |
31 |
42 |
{record.hostname}
43 |
44 |
45 |
46 |
47 | Value
48 |
49 |
50 |
61 |
{record.value}
62 |
63 |
64 |
65 |
66 | Status
67 |
68 | {record.status === "Verified" ? (
69 |
70 |
71 | {record.status}
72 |
73 | ) : (
74 |
75 |
76 | {record.status}
77 |
78 | )}
79 |
80 |
81 | {index < records.length - 1 &&
}
82 |
83 | ))}
84 |
85 |
86 |
87 | );
88 | }
89 |
--------------------------------------------------------------------------------
/backend/cmd/xsrv/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "context"
5 | "crypto/tls"
6 | "flag"
7 | "fmt"
8 | "github.com/redis/go-redis/v9"
9 | "log/slog"
10 | "net/http"
11 | "os"
12 | "time"
13 |
14 | "github.com/jackc/pgx/v5/pgxpool"
15 |
16 | "github.com/zyghq/zyg"
17 | "github.com/zyghq/zyg/adapters/repository"
18 | "github.com/zyghq/zyg/adapters/xhandler"
19 | "github.com/zyghq/zyg/services"
20 | )
21 |
22 | var host = flag.String("host", "0.0.0.0", "host")
23 | var port = flag.String("port", "8080", "port")
24 |
25 | var addr string
26 |
27 | func run(ctx context.Context) error {
28 | var err error
29 | ctx, cancel := context.WithCancel(ctx)
30 | defer cancel()
31 |
32 | // get postgres connection string from env
33 | pgConnStr, err := zyg.GetEnv("DATABASE_URL")
34 | if err != nil {
35 | return fmt.Errorf("failed to get DATABASE_URL env got error: %v", err)
36 | }
37 |
38 | // create pg connection pool
39 | db, err := pgxpool.New(ctx, pgConnStr)
40 | if err != nil {
41 | return fmt.Errorf("unable to create pg connection pool: %v", err)
42 | }
43 |
44 | defer db.Close()
45 |
46 | // make sure db is up and running
47 | var tm time.Time
48 | err = db.QueryRow(ctx, "SELECT NOW()").Scan(&tm)
49 | if err != nil {
50 | return fmt.Errorf("db query failed got error: %v", err)
51 | }
52 |
53 | slog.Info("database", slog.Any("db time", tm.Format(time.RFC1123)))
54 |
55 | // Redis options
56 | opts := &redis.Options{
57 | Addr: zyg.RedisAddr(),
58 | Username: zyg.RedisUsername(),
59 | Password: zyg.RedisPassword(),
60 | DB: 0,
61 | }
62 |
63 | if zyg.RedisTLSEnabled() {
64 | opts.TLSConfig = &tls.Config{
65 | InsecureSkipVerify: true,
66 | }
67 | }
68 |
69 | rdb := redis.NewClient(opts)
70 |
71 | defer func(rdb *redis.Client) {
72 | err := rdb.Close()
73 | if err != nil {
74 | slog.Error("failed to close redis client", slog.Any("err", err))
75 | }
76 | }(rdb)
77 |
78 | // Perform basic diagnostic to check if the connection is working
79 | // Expected result > ping: PONG
80 | // If Redis is not running, error case is taken instead
81 | status, err := rdb.Ping(ctx).Result()
82 | if err != nil {
83 | return fmt.Errorf("failed to ping redis got error: %v", err)
84 | }
85 | slog.Info("redis", slog.Any("status", status))
86 |
87 | // init respective stores
88 | workspaceStore := repository.NewWorkspaceDB(db)
89 | memberStore := repository.NewMemberDB(db)
90 | customerStore := repository.NewCustomerDB(db)
91 | threadStore := repository.NewThreadDB(db, rdb)
92 |
93 | // init respective services
94 | authService := services.NewCustomerAuthService(customerStore)
95 | workspaceService := services.NewWorkspaceService(workspaceStore, memberStore, customerStore)
96 | customerService := services.NewCustomerService(customerStore)
97 | threadService := services.NewThreadService(threadStore)
98 |
99 | // init server
100 | srv := xhandler.NewServer(
101 | authService,
102 | workspaceService,
103 | customerService,
104 | threadService,
105 | )
106 |
107 | addr = fmt.Sprintf("%s:%s", *host, *port)
108 | httpServer := &http.Server{
109 | Addr: addr,
110 | Handler: srv,
111 | ReadTimeout: 30 * time.Second,
112 | WriteTimeout: 90 * time.Second,
113 | IdleTimeout: 5 * time.Minute,
114 | ReadHeaderTimeout: time.Minute,
115 | }
116 |
117 | slog.Info("server up and running", slog.String("addr", addr))
118 |
119 | err = httpServer.ListenAndServe()
120 | return err
121 | }
122 |
123 | func main() {
124 | flag.Parse()
125 | ctx := context.Background()
126 | if err := run(ctx); err != nil {
127 | _, err := fmt.Fprintf(os.Stderr, "%s\n", err)
128 | if err != nil {
129 | return
130 | }
131 | os.Exit(1)
132 | }
133 | }
134 |
--------------------------------------------------------------------------------
/backend/static/templates/mails/kyc.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | {{ .PreviewText }}
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |  |
22 |
23 |
24 |
25 | You started a conversation.
26 |
35 | ❤️ Zyg ・ Open source, made with love around the world ❤️
36 | |
37 |
38 |
39 |
40 |
41 |
42 |
--------------------------------------------------------------------------------
/backend/adapters/handler/middleware.go:
--------------------------------------------------------------------------------
1 | package handler
2 |
3 | import (
4 | "crypto/subtle"
5 | "github.com/getsentry/sentry-go"
6 | "log/slog"
7 | "net/http"
8 | "time"
9 |
10 | "github.com/zyghq/zyg/ports"
11 | )
12 |
13 | type wrappedWriter struct {
14 | http.ResponseWriter
15 | statusCode int
16 | }
17 |
18 | func (w *wrappedWriter) WriteHeader(statusCode int) {
19 | w.ResponseWriter.WriteHeader(statusCode)
20 | w.statusCode = statusCode
21 | }
22 |
23 | func LoggingMiddleware(next http.Handler) http.Handler {
24 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
25 | start := time.Now().UTC()
26 | wrapper := &wrappedWriter{
27 | ResponseWriter: w,
28 | statusCode: http.StatusOK, // default
29 | }
30 | next.ServeHTTP(wrapper, r)
31 | slog.Info(
32 | "http",
33 | slog.Int("status", wrapper.statusCode),
34 | slog.String("method", r.Method),
35 | slog.String("path", r.URL.Path),
36 | slog.String("duration", time.Since(start).String()),
37 | )
38 | })
39 | }
40 |
41 | type EnsureAuthAccount struct {
42 | handler AuthenticatedAccountHandler
43 | authz ports.AuthServicer
44 | }
45 |
46 | func NewEnsureAuthAccount(
47 | handler AuthenticatedAccountHandler, as ports.AuthServicer) *EnsureAuthAccount {
48 | return &EnsureAuthAccount{
49 | handler: handler,
50 | authz: as,
51 | }
52 | }
53 |
54 | func (ea *EnsureAuthAccount) ServeHTTP(w http.ResponseWriter, r *http.Request) {
55 | scheme, cred, err := CheckAuthCredentials(r)
56 | if err != nil {
57 | http.Error(w, err.Error(), http.StatusUnauthorized)
58 | return
59 | }
60 | account, err := AuthenticateAccount(r.Context(), ea.authz, scheme, cred)
61 | if err != nil {
62 | http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
63 | return
64 | }
65 | ea.handler(w, r, &account)
66 | }
67 |
68 | type EnsureMemberAuth struct {
69 | handler AuthenticatedMemberHandler
70 | authz ports.AuthServicer
71 | }
72 |
73 | func NewEnsureMemberAuth(
74 | handler AuthenticatedMemberHandler, as ports.AuthServicer) *EnsureMemberAuth {
75 | return &EnsureMemberAuth{
76 | handler: handler,
77 | authz: as,
78 | }
79 | }
80 |
81 | func (em *EnsureMemberAuth) ServeHTTP(w http.ResponseWriter, r *http.Request) {
82 | workspaceId := r.PathValue("workspaceId")
83 | scheme, cred, err := CheckAuthCredentials(r)
84 | if err != nil {
85 | http.Error(w, err.Error(), http.StatusUnauthorized)
86 | return
87 | }
88 | member, err := AuthenticateMember(r.Context(), em.authz, workspaceId, scheme, cred)
89 | if err != nil {
90 | http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
91 | return
92 | }
93 | ctx := r.Context()
94 | hub := sentry.GetHubFromContext(ctx)
95 | hub.Scope().SetTag("workspaceId", workspaceId)
96 | hub.Scope().SetUser(sentry.User{
97 | ID: member.MemberId,
98 | Name: member.Name,
99 | })
100 | em.handler(w, r, &member)
101 | }
102 |
103 | func BasicAuthWebhook(handler http.HandlerFunc, username, password string) http.HandlerFunc {
104 | return func(w http.ResponseWriter, r *http.Request) {
105 | // Extract credentials from request header
106 | user, pass, ok := r.BasicAuth()
107 |
108 | ctx := r.Context()
109 | hub := sentry.GetHubFromContext(ctx)
110 |
111 | // Check if auth is provided and credentials match
112 | if !ok || subtle.ConstantTimeCompare([]byte(user), []byte(username)) != 1 ||
113 | subtle.ConstantTimeCompare([]byte(pass), []byte(password)) != 1 {
114 | hub.CaptureMessage("Unauthorized Webhook Requested")
115 | w.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`)
116 | http.Error(w, "Unauthorized", http.StatusUnauthorized)
117 | return
118 | }
119 | // Call the protected handler
120 | handler(w, r)
121 | }
122 | }
123 |
--------------------------------------------------------------------------------
/frontend/src/assets/react.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/frontend/src/components/workspace/thread/message-form.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@/components/ui/button";
2 | import { Form, FormControl, FormField, FormItem } from "@/components/ui/form";
3 | import { Textarea } from "@/components/ui/textarea";
4 | import { sendThreadChatMessage } from "@/db/api";
5 | import { ThreadMessageResponse } from "@/db/schema";
6 | import { zodResolver } from "@hookform/resolvers/zod";
7 | import { useMutation } from "@tanstack/react-query";
8 | import { SendHorizonalIcon } from "lucide-react";
9 | import React from "react";
10 | import { useForm } from "react-hook-form";
11 | import { z } from "zod";
12 |
13 | const formSchema = z.object({
14 | message: z.string().min(1, "Message is required"),
15 | });
16 |
17 | type MessageFormProps = {
18 | customerName: string;
19 | refetch: () => void;
20 | threadId: string;
21 | token: string;
22 | workspaceId: string;
23 | };
24 |
25 | function SubmitButton({ isDisabled }: { isDisabled: boolean }) {
26 | return (
27 |
37 | );
38 | }
39 |
40 | export function MessageForm({
41 | customerName,
42 | refetch,
43 | threadId,
44 | token,
45 | workspaceId,
46 | }: MessageFormProps) {
47 | const form = useForm({
48 | defaultValues: {
49 | message: "",
50 | },
51 | resolver: zodResolver(formSchema),
52 | });
53 |
54 | const mutation = useMutation({
55 | mutationFn: async (values: { message: string }) => {
56 | const { message } = values;
57 | const { data, error } = await sendThreadChatMessage(
58 | token,
59 | workspaceId,
60 | threadId,
61 | { message },
62 | );
63 | if (error) {
64 | throw new Error(error.message);
65 | }
66 | if (!data) {
67 | throw new Error("no data returned");
68 | }
69 | return data as ThreadMessageResponse;
70 | },
71 | onError: (error) => {
72 | console.error(error);
73 | form.setError("message", {
74 | message: "Something went wrong. Please try again later.",
75 | });
76 | },
77 | onSuccess: (data) => {
78 | console.log("onSuccess");
79 | console.log(data);
80 | refetch();
81 | form.reset({ message: "" });
82 | },
83 | });
84 |
85 | const { formState } = form;
86 | const { isSubmitting } = formState;
87 |
88 | const onEnterPress = (e: React.KeyboardEvent) => {
89 | if (e.key === "Enter" && !e.shiftKey) {
90 | // Capitalize "Enter" correctly
91 | e.preventDefault();
92 | (e.target as HTMLTextAreaElement).form?.requestSubmit(); // Cast e.target to HTMLTextAreaElement
93 | }
94 | };
95 |
96 | async function onSubmit(values: { message: string }) {
97 | const { message } = values;
98 | await mutation.mutateAsync({ message });
99 | }
100 |
101 | return (
102 |
127 |
128 | );
129 | }
130 |
--------------------------------------------------------------------------------
/frontend/src/db/helpers.ts:
--------------------------------------------------------------------------------
1 | import { SortBy } from "@/db/store";
2 |
3 | export function threadStatusVerboseName(key: string): string {
4 | switch (key) {
5 | case "hold":
6 | return "Hold";
7 | case "needs_first_response":
8 | return "Needs First Response";
9 | case "needs_next_response":
10 | return "Needs Next Response";
11 | case "resolved":
12 | return "Resolved";
13 | case "spam":
14 | return "Spam";
15 | case "waiting_on_customer":
16 | return "Waiting on Customer";
17 | default:
18 | return key;
19 | }
20 | }
21 |
22 | export function ThreadSortKeyHumanized(key: SortBy): string {
23 | switch (key) {
24 | case "created-asc": // when the thread is created
25 | return "Created, oldest first";
26 | case "created-dsc": // when the thread is created
27 | return "Created, newest first";
28 | case "inbound-message-dsc": // when the inbound message was received
29 | return "Most recent message";
30 | case "outbound-message-dsc": // when the outbound message was sent
31 | return "Most recent reply";
32 | case "priority-asc": // when the thread priority is changed
33 | return "Priority, highest first";
34 | case "priority-dsc": // when the thread priority is changed
35 | return "Priority, lowest first";
36 | case "status-changed-asc": // when the thread status is changed
37 | return "Status changed at, oldest first";
38 | case "status-changed-dsc": // when the thread status is changed
39 | return "Status changed at, newest first";
40 | default:
41 | return key;
42 | }
43 | }
44 |
45 | export function customerRoleVerboseName(key: string): string {
46 | switch (key) {
47 | case "engaged":
48 | return "Engaged";
49 | case "lead":
50 | return "Lead";
51 | case "visitor":
52 | return "Visitor";
53 | default:
54 | return key;
55 | }
56 | }
57 |
58 | export const sortKeys = [
59 | "created-dsc",
60 | "created-asc",
61 | "status-changed-dsc",
62 | "status-changed-asc",
63 | "inbound-message-dsc",
64 | "outbound-message-dsc",
65 | "priority-asc",
66 | "priority-dsc",
67 | ] as const;
68 |
69 | export const todoThreadStages = [
70 | "needs_first_response",
71 | "waiting_on_customer",
72 | "hold",
73 | "needs_next_response",
74 | ] as const;
75 |
76 | export function getFromLocalStorage(key: string): any | null | string {
77 | try {
78 | // Get the item from localStorage
79 | const item = localStorage.getItem(key);
80 |
81 | // Check if it's already a string, return if so
82 | if (item === null || item === undefined) {
83 | return null;
84 | }
85 |
86 | // Try to parse as JSON, if it fails return as string
87 | try {
88 | return JSON.parse(item);
89 | } catch (error) {
90 | return item; // If not JSON, return the raw string
91 | }
92 | } catch (error) {
93 | console.error("Error accessing localStorage:", error);
94 | return null; // Return null if there's an error
95 | }
96 | }
97 |
98 | export function setInLocalStorage(key: string, value: any) {
99 | try {
100 | // Check if the value is an object, if so, stringify it before storing
101 | const item = typeof value === "object" ? JSON.stringify(value) : value;
102 |
103 | // Store the item in localStorage
104 | localStorage.setItem(key, item);
105 | } catch (error) {
106 | console.error("error setting in localStorage:", error);
107 | }
108 | }
109 |
110 |
111 |
112 | export function getInitials(name: string): string {
113 | // Split the name by spaces
114 | const nameParts = name.trim().split(/\s+/);
115 |
116 | if (nameParts.length === 1) {
117 | // If there's only one name, return the first character
118 | return nameParts[0].charAt(0).toUpperCase();
119 | } else {
120 | // Otherwise, return the first letter of the first and last names
121 | const firstInitial = nameParts[0].charAt(0).toUpperCase();
122 | const lastInitial = nameParts[nameParts.length - 1].charAt(0).toUpperCase();
123 | return firstInitial + lastInitial;
124 | }
125 | }
126 |
--------------------------------------------------------------------------------
/frontend/src/components/ui/dialog.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as DialogPrimitive from "@radix-ui/react-dialog"
3 | import { Cross2Icon } from "@radix-ui/react-icons"
4 |
5 | import { cn } from "@/lib/utils"
6 |
7 | const Dialog = DialogPrimitive.Root
8 |
9 | const DialogTrigger = DialogPrimitive.Trigger
10 |
11 | const DialogPortal = DialogPrimitive.Portal
12 |
13 | const DialogClose = DialogPrimitive.Close
14 |
15 | const DialogOverlay = React.forwardRef<
16 | React.ElementRef,
17 | React.ComponentPropsWithoutRef
18 | >(({ className, ...props }, ref) => (
19 |
27 | ))
28 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
29 |
30 | const DialogContent = React.forwardRef<
31 | React.ElementRef,
32 | React.ComponentPropsWithoutRef
33 | >(({ className, children, ...props }, ref) => (
34 |
35 |
36 |
44 | {children}
45 |
46 |
47 | Close
48 |
49 |
50 |
51 | ))
52 | DialogContent.displayName = DialogPrimitive.Content.displayName
53 |
54 | const DialogHeader = ({
55 | className,
56 | ...props
57 | }: React.HTMLAttributes) => (
58 |
65 | )
66 | DialogHeader.displayName = "DialogHeader"
67 |
68 | const DialogFooter = ({
69 | className,
70 | ...props
71 | }: React.HTMLAttributes) => (
72 |
79 | )
80 | DialogFooter.displayName = "DialogFooter"
81 |
82 | const DialogTitle = React.forwardRef<
83 | React.ElementRef,
84 | React.ComponentPropsWithoutRef
85 | >(({ className, ...props }, ref) => (
86 |
94 | ))
95 | DialogTitle.displayName = DialogPrimitive.Title.displayName
96 |
97 | const DialogDescription = React.forwardRef<
98 | React.ElementRef,
99 | React.ComponentPropsWithoutRef
100 | >(({ className, ...props }, ref) => (
101 |
106 | ))
107 | DialogDescription.displayName = DialogPrimitive.Description.displayName
108 |
109 | export {
110 | Dialog,
111 | DialogPortal,
112 | DialogOverlay,
113 | DialogTrigger,
114 | DialogClose,
115 | DialogContent,
116 | DialogHeader,
117 | DialogFooter,
118 | DialogTitle,
119 | DialogDescription,
120 | }
121 |
--------------------------------------------------------------------------------
/frontend/src/components/workspace/thread/threads.tsx:
--------------------------------------------------------------------------------
1 | import { channelIcon, stageIcon } from "@/components/icons";
2 | import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
3 | import { getInitials } from "@/db/helpers";
4 | import { Thread } from "@/db/models";
5 | import { WorkspaceStoreState } from "@/db/store";
6 | import { cn } from "@/lib/utils";
7 | import { useWorkspaceStore } from "@/providers";
8 | import { PersonIcon } from "@radix-ui/react-icons";
9 | import { Link } from "@tanstack/react-router";
10 | import { formatDistanceToNowStrict } from "date-fns";
11 | import { useStore } from "zustand";
12 |
13 | function ThreadItem({
14 | item,
15 | variant = "default",
16 | workspaceId,
17 | }: {
18 | item: Thread;
19 | variant?: string;
20 | workspaceId: string;
21 | }) {
22 | // const WorkspaceStore = useRouteContext({
23 | // from: "/_auth/workspaces/$workspaceId/_workspace",
24 | // select: (context) => context.WorkspaceStore,
25 | // });
26 | const workspaceStore = useWorkspaceStore();
27 | const customerName = useStore(workspaceStore, (state: WorkspaceStoreState) =>
28 | state.viewCustomerName(state, item.customerId),
29 | );
30 |
31 | // const bottomRef = React.useRef(null);
32 |
33 | // React.useEffect(() => {
34 | // if (bottomRef.current) {
35 | // bottomRef.current.scrollIntoView({ behavior: "smooth" });
36 | // }
37 | // }, []);
38 |
39 | return (
40 |
52 |
53 |
54 |
55 |
56 |
61 | {getInitials(customerName)}
62 |
63 |
{customerName}
64 |
65 | {stageIcon(item.stage, {
66 | className: "w-4 h-4 text-indigo-500 my-auto",
67 | })}
68 |
69 |
{item.title}
70 |
71 | {item.previewText}
72 |
73 |
74 |
75 |
76 |
77 | {channelIcon(item.channel, {
78 | className: "h-4 w-4 text-muted-foreground",
79 | })}
80 |
81 | {formatDistanceToNowStrict(new Date(item.updatedAt), {
82 | addSuffix: true,
83 | })}
84 |
85 | {item.assigneeId ? (
86 |
87 |
90 | M
91 |
92 | ) : (
93 |
94 | )}
95 |
96 |
97 |
98 |
99 | );
100 | }
101 |
102 | export function ThreadList({
103 | threads,
104 | variant = "default",
105 | workspaceId,
106 | }: {
107 | threads: Thread[];
108 | variant?: string;
109 | workspaceId: string;
110 | }) {
111 | return (
112 |
115 | {threads.map((item: Thread) => (
116 |
122 | ))}
123 |
124 | );
125 | }
126 |
--------------------------------------------------------------------------------
/backend/adapters/repository/member.go:
--------------------------------------------------------------------------------
1 | package repository
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "log/slog"
7 |
8 | "github.com/cristalhq/builq"
9 | "github.com/zyghq/zyg"
10 |
11 | "github.com/jackc/pgx/v5"
12 | "github.com/zyghq/zyg/models"
13 | )
14 |
15 | // Returns the required columns for the member table.
16 | // The order of the columns matters when returning the results.
17 | func memberCols() builq.Columns {
18 | return builq.Columns{
19 | "member_id", // PK
20 | "workspace_id", // FK to workspace
21 | "name",
22 | "role",
23 | "created_at",
24 | "updated_at",
25 | }
26 | }
27 |
28 | // LookupByWorkspaceAccountId returns the member by workspace ID and account ID.
29 | // The member is uniquely identified by the combination of `workspace_id` and `account_id`
30 | // Human Member can authenticate to the workspace, hence the link to account ID.
31 | func (m *MemberDB) LookupByWorkspaceAccountId(
32 | ctx context.Context, workspaceId string, accountId string) (models.Member, error) {
33 | var member models.Member
34 |
35 | q := builq.New()
36 | q("SELECT %s FROM member", memberCols())
37 | q("WHERE workspace_id = %$ AND account_id = %$", workspaceId, accountId)
38 |
39 | stmt, _, err := q.Build()
40 | if err != nil {
41 | slog.Error("failed to build query", slog.Any("err", err))
42 | return models.Member{}, ErrQuery
43 | }
44 |
45 | if zyg.DBQueryDebug() {
46 | debug := q.DebugBuild()
47 | debugQuery(debug)
48 | }
49 |
50 | err = m.db.QueryRow(ctx, stmt, workspaceId, accountId).Scan(
51 | &member.MemberId, &member.WorkspaceId,
52 | &member.Name, &member.Role,
53 | &member.CreatedAt, &member.UpdatedAt,
54 | )
55 |
56 | if errors.Is(err, pgx.ErrNoRows) {
57 | slog.Error("no rows returned", slog.Any("error", err))
58 | return models.Member{}, ErrEmpty
59 | }
60 | if err != nil {
61 | slog.Error("failed to query", slog.Any("error", err))
62 | return models.Member{}, ErrQuery
63 | }
64 | return member, nil
65 | }
66 |
67 | // FetchMembersByWorkspaceId returns the members by workspace ID.
68 | func (m *MemberDB) FetchMembersByWorkspaceId(
69 | ctx context.Context, workspaceId string) ([]models.Member, error) {
70 | var member models.Member
71 | limit := 100
72 | members := make([]models.Member, 0, limit)
73 |
74 | q := builq.New()
75 | q("SELECT %s FROM member", memberCols())
76 | q("WHERE workspace_id = %$", workspaceId)
77 | q("ORDER BY created_at DESC")
78 | q("LIMIT %d", limit)
79 |
80 | stmt, _, err := q.Build()
81 | if err != nil {
82 | slog.Error("failed to build query", slog.Any("err", err))
83 | return []models.Member{}, ErrQuery
84 | }
85 |
86 | if zyg.DBQueryDebug() {
87 | debug := q.DebugBuild()
88 | debugQuery(debug)
89 | }
90 |
91 | rows, _ := m.db.Query(ctx, stmt, workspaceId)
92 |
93 | defer rows.Close()
94 |
95 | _, err = pgx.ForEachRow(rows, []any{
96 | &member.MemberId, &member.WorkspaceId,
97 | &member.Name, &member.Role,
98 | &member.CreatedAt, &member.UpdatedAt,
99 | }, func() error {
100 | members = append(members, member)
101 | return nil
102 | })
103 | if err != nil {
104 | slog.Error("failed to query", slog.Any("error", err))
105 | return []models.Member{}, ErrQuery
106 | }
107 | return members, nil
108 | }
109 |
110 | // FetchByWorkspaceMemberId returns the member by workspace ID and member ID.
111 | // The member is uniquely identified by the combination of `workspace_id` and `member_id`
112 | func (m *MemberDB) FetchByWorkspaceMemberId(
113 | ctx context.Context, workspaceId string, memberId string) (models.Member, error) {
114 | var member models.Member
115 |
116 | q := builq.New()
117 | q("SELECT %s FROM member", memberCols())
118 | q("WHERE workspace_id = %$ AND member_id = %$", workspaceId, memberId)
119 |
120 | stmt, _, err := q.Build()
121 | if err != nil {
122 | slog.Error("failed to build query", slog.Any("err", err))
123 | return models.Member{}, ErrQuery
124 | }
125 |
126 | if zyg.DBQueryDebug() {
127 | debug := q.DebugBuild()
128 | debugQuery(debug)
129 | }
130 |
131 | err = m.db.QueryRow(ctx, stmt, workspaceId, memberId).Scan(
132 | &member.MemberId, &member.WorkspaceId,
133 | &member.Name, &member.Role,
134 | &member.CreatedAt, &member.UpdatedAt,
135 | )
136 |
137 | if errors.Is(err, pgx.ErrNoRows) {
138 | slog.Error("no rows returned", slog.Any("error", err))
139 | return models.Member{}, ErrEmpty
140 | }
141 | if err != nil {
142 | slog.Error("failed to query", slog.Any("error", err))
143 | return models.Member{}, ErrQuery
144 | }
145 | return member, nil
146 | }
147 |
--------------------------------------------------------------------------------
/backend/config.go:
--------------------------------------------------------------------------------
1 | package zyg
2 |
3 | import (
4 | "fmt"
5 | "github.com/google/uuid"
6 | "os"
7 | "strconv"
8 | )
9 |
10 | const DefaultSecretKeyLength = 64
11 |
12 | func GetEnv(key string) (string, error) {
13 | value, status := os.LookupEnv(key)
14 | if !status {
15 | return "", fmt.Errorf("env `%s` is not set", key)
16 | }
17 | return value, nil
18 | }
19 |
20 | func GetAvatarBaseURL() string {
21 | value, ok := os.LookupEnv("AVATAR_BASE_URL")
22 | if !ok {
23 | return "https://avatar.vercel.sh/" // probably self-host?
24 | }
25 | return value
26 | }
27 |
28 | func DBQueryDebug() bool {
29 | debug, err := strconv.ParseBool(os.Getenv("ZYG_DB_QUERY_DEBUG"))
30 | if err != nil {
31 | return false
32 | }
33 | return debug
34 | }
35 |
36 | func GetXServerUrl() string {
37 | value, ok := os.LookupEnv("ZYG_XSERVER_URL")
38 | if !ok {
39 | return "http://localhost:8000"
40 | }
41 | return value
42 | }
43 |
44 | func ServerDomain() string {
45 | value, ok := os.LookupEnv("ZYG_SERVER_DOMAIN")
46 | if !ok {
47 | return "localhost"
48 | }
49 | return value
50 | }
51 |
52 | func ServerProto() string {
53 | value, ok := os.LookupEnv("ZYG_SERVER_PROTO")
54 | if !ok {
55 | return "http"
56 | }
57 | return value
58 | }
59 |
60 | func LandingPageUrl() string {
61 | value, ok := os.LookupEnv("ZYG_URL")
62 | if !ok {
63 | return "https://zyg.ai"
64 | }
65 | return value
66 | }
67 |
68 | func ResendApiKey() string {
69 | value, ok := os.LookupEnv("RESEND_API_KEY")
70 | if !ok {
71 | return ""
72 | }
73 | return value
74 | }
75 |
76 | func CFAccountId() string {
77 | value, ok := os.LookupEnv("CF_ACCOUNT_ID")
78 | if !ok {
79 | return ""
80 | }
81 | return value
82 | }
83 |
84 | func R2AccessKeyId() string {
85 | value, ok := os.LookupEnv("R2_ACCESS_KEY_ID")
86 | if !ok {
87 | return ""
88 | }
89 | return value
90 | }
91 |
92 | func R2AccessSecretKey() string {
93 | value, ok := os.LookupEnv("R2_ACCESS_SECRET_KEY")
94 | if !ok {
95 | return ""
96 | }
97 | return value
98 | }
99 |
100 | func S3Bucket() string {
101 | value, ok := os.LookupEnv("S3_BUCKET")
102 | if !ok {
103 | return "zygdev"
104 | }
105 | return value
106 | }
107 |
108 | func RedisAddr() string {
109 | value, ok := os.LookupEnv("REDIS_ADDR")
110 | if !ok {
111 | return "localhost:6379"
112 | }
113 | return value
114 | }
115 |
116 | func RedisUsername() string {
117 | value, ok := os.LookupEnv("REDIS_USERNAME")
118 | if !ok {
119 | return "zygdev"
120 | }
121 | return value
122 | }
123 |
124 | func RedisPassword() string {
125 | value, ok := os.LookupEnv("REDIS_PASSWORD")
126 | if !ok {
127 | return ""
128 | }
129 | return value
130 | }
131 |
132 | func RedisTLSEnabled() bool {
133 | enabled, err := strconv.ParseBool(os.Getenv("REDIS_TLS_ENABLED"))
134 | if err != nil {
135 | return false
136 | }
137 | return enabled
138 | }
139 |
140 | func SentryDebugEnabled() bool {
141 | enabled, err := strconv.ParseBool(os.Getenv("SENTRY_DEBUG_ENABLED"))
142 | if err != nil {
143 | return false
144 | }
145 | return enabled
146 | }
147 |
148 | func SentryEnv() string {
149 | value, ok := os.LookupEnv("SENTRY_ENV")
150 | if !ok {
151 | return "staging"
152 | }
153 | return value
154 | }
155 |
156 | func PostmarkAccountToken() string {
157 | value, ok := os.LookupEnv("POSTMARK_ACCOUNT_TOKEN")
158 | if !ok {
159 | return ""
160 | }
161 | return value
162 | }
163 |
164 | // WebhookUsername retrieves the "WEBHOOK_USERNAME" environment variable or generates a UUID if not found.
165 | // If not set then generate random username for security reasons.
166 | func WebhookUsername() string {
167 | value, ok := os.LookupEnv("WEBHOOK_USERNAME")
168 | if !ok {
169 | u, _ := uuid.NewUUID()
170 | return u.String()
171 | }
172 | return value
173 | }
174 |
175 | // WebhookPassword retrieves the "WEBHOOK_PASSWORD" environment variable or generates a new UUID if not set.
176 | // If not set then generate random username for security reasons.
177 | func WebhookPassword() string {
178 | value, ok := os.LookupEnv("WEBHOOK_PASSWORD")
179 | if !ok {
180 | u, _ := uuid.NewUUID()
181 | return u.String()
182 | }
183 | return value
184 | }
185 |
186 | func WebhookUrl() string {
187 | proto := ServerProto()
188 | domain := ServerDomain()
189 | u := WebhookUsername()
190 | p := WebhookPassword()
191 | return fmt.Sprintf("%s://%s:%s@%s", proto, u, p, domain)
192 | }
193 |
194 | func PostmarkDeliveryDomain() string {
195 | value, ok := os.LookupEnv("POSTMARK_DELIVERY_DOMAIN")
196 | if !ok {
197 | return "mtasv.net"
198 | }
199 | return value
200 | }
201 |
--------------------------------------------------------------------------------
/backend/integrations/email/email.go:
--------------------------------------------------------------------------------
1 | package email
2 |
3 | import (
4 | "context"
5 | "encoding/json"
6 | "github.com/zyghq/postmark"
7 | "github.com/zyghq/zyg/integrations"
8 | "github.com/zyghq/zyg/models"
9 | "log/slog"
10 | "time"
11 | )
12 |
13 | // PostmarkInboundMessageReq represents inbound webhook request from Postmark
14 | // with the raw JSON payload.
15 | type PostmarkInboundMessageReq struct {
16 | postmark.InboundWebhook
17 | Payload map[string]interface{}
18 | }
19 |
20 | // ToPostmarkInboundMessage converts a PostmarkInboundMessageReq to a PostmarkInboundMessage model instance.
21 | func (p *PostmarkInboundMessageReq) ToPostmarkInboundMessage() models.PostmarkInboundMessage {
22 | now := time.Now().UTC()
23 | message := models.PostmarkInboundMessage{
24 | Payload: p.Payload,
25 | PostmarkMessageId: p.MessageID, // Postmark MessageID
26 | Subject: p.Subject,
27 | TextBody: p.TextBody,
28 | HTMLBody: p.HTMLBody,
29 | FromEmail: p.FromFull.Email,
30 | FromName: p.FromFull.Name,
31 | CreatedAt: now,
32 | UpdatedAt: now,
33 | Attachments: p.ToPostmarkMessageAttachments(),
34 | }
35 | for _, h := range p.Headers {
36 | if h.Name == "Message-ID" {
37 | message.MailMessageId = h.Value // From mail protocol headers
38 | }
39 | if h.Name == "In-Reply-To" {
40 | message.ReplyMailMessageId = &h.Value // From mail protocol headers
41 | }
42 | }
43 | // If this message is a reply to an existing mail message ID
44 | // check if the stripped text is provided - take that as the text body instead.
45 | if message.ReplyMailMessageId != nil && p.StrippedTextReply != "" {
46 | message.TextBody = p.StrippedTextReply
47 | }
48 | return message
49 | }
50 |
51 | func (p *PostmarkInboundMessageReq) ToPostmarkMessageAttachments() []models.PostmarkMessageAttachment {
52 | var attachments []models.PostmarkMessageAttachment
53 | now := time.Now().UTC()
54 | for _, m := range p.Attachments {
55 | attachments = append(attachments, models.PostmarkMessageAttachment{
56 | CreatedAt: now,
57 | UpdatedAt: now,
58 | Name: m.Name,
59 | ContentType: m.ContentType,
60 | Content: m.Content,
61 | })
62 | }
63 | return attachments
64 | }
65 |
66 | // FromPostmarkInboundRequest parses an inbound webhook payload from Postmark into a
67 | // PostmarkInboundMessageReq structure.
68 | // It takes a map[string]interface{} request payload and returns the parsed
69 | // PostmarkInboundMessageReq or an error if parsing fails.
70 | func FromPostmarkInboundRequest(reqp map[string]interface{}) (PostmarkInboundMessageReq, error) {
71 | // Convert to JSON bytes
72 | jsonBytes, err := json.Marshal(reqp)
73 | if err != nil {
74 | return PostmarkInboundMessageReq{}, err
75 | }
76 |
77 | // Parse JSON bytes into struct
78 | var payload PostmarkInboundMessageReq
79 | if err := json.Unmarshal(jsonBytes, &payload); err != nil {
80 | return PostmarkInboundMessageReq{}, err
81 | }
82 |
83 | payload.Payload = reqp
84 | return payload, nil
85 | }
86 |
87 | type PostmarkEmailReqOption func(req *postmark.Email)
88 |
89 | func NewPostmarkEmailReq(subject, from, to string, opts ...PostmarkEmailReqOption) *postmark.Email {
90 | req := &postmark.Email{
91 | Subject: subject,
92 | From: from,
93 | To: to,
94 | }
95 | for _, opt := range opts {
96 | opt(req)
97 | }
98 | return req
99 | }
100 |
101 | func SetPostmarkTextBody(textBody string) PostmarkEmailReqOption {
102 | return func(req *postmark.Email) {
103 | req.TextBody = textBody
104 | }
105 | }
106 |
107 | func SetPostmarkHTMLBody(htmlBody string) PostmarkEmailReqOption {
108 | return func(req *postmark.Email) {
109 | req.HTMLBody = htmlBody
110 | }
111 | }
112 |
113 | func WithPostmarkHeader(name, value string) PostmarkEmailReqOption {
114 | return func(req *postmark.Email) {
115 | req.Headers = append(req.Headers, postmark.Header{
116 | Name: name,
117 | Value: value,
118 | })
119 | }
120 | }
121 |
122 | func SetPostmarkTag(tag string) PostmarkEmailReqOption {
123 | return func(req *postmark.Email) {
124 | req.Tag = tag
125 | }
126 | }
127 |
128 | func SendPostmarkMail(
129 | ctx context.Context, setting models.PostmarkMailServerSetting, email *postmark.Email,
130 | ) (postmark.EmailResponse, error) {
131 | client := postmark.NewClient(setting.ServerToken, "")
132 | r, err := client.SendEmail(ctx, *email)
133 | if err != nil {
134 | slog.Error("failed to send email", slog.Any("error", err), slog.Any("email", email))
135 | return postmark.EmailResponse{}, integrations.ErrPostmarkSendMail
136 | }
137 | return r, nil
138 | }
139 |
--------------------------------------------------------------------------------
/backend/services/auth.go:
--------------------------------------------------------------------------------
1 | package services
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "fmt"
7 |
8 | "github.com/golang-jwt/jwt/v5"
9 | "github.com/zyghq/zyg/adapters/repository"
10 | "github.com/zyghq/zyg/models"
11 | "github.com/zyghq/zyg/ports"
12 | )
13 |
14 | const DefaultAuthProvider string = "supabase"
15 |
16 | func ParseJWTToken(
17 | token string, hmacSecret []byte) (ac models.AuthJWTClaims, err error) {
18 | t, err := jwt.ParseWithClaims(
19 | token, &models.AuthJWTClaims{}, func(token *jwt.Token) (interface{}, error) {
20 | if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
21 | return nil, fmt.Errorf("%v", token.Header["alg"])
22 | }
23 | return hmacSecret, nil
24 | })
25 |
26 | if err != nil {
27 | return ac, fmt.Errorf("%v", err)
28 | } else if claims, ok := t.Claims.(*models.AuthJWTClaims); ok {
29 | return *claims, nil
30 | }
31 | return ac, fmt.Errorf("error parsing jwt token")
32 | }
33 |
34 | func ParseCustomerJWTToken(
35 | token string, hmacSecret []byte) (cc models.CustomerJWTClaims, err error) {
36 | t, err := jwt.ParseWithClaims(
37 | token, &models.CustomerJWTClaims{}, func(token *jwt.Token) (interface{}, error) {
38 | if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
39 | return nil, fmt.Errorf("%v", token.Header["alg"])
40 | }
41 | return hmacSecret, nil
42 | })
43 |
44 | if err != nil {
45 | return cc, fmt.Errorf("%v", err)
46 | } else if claims, ok := t.Claims.(*models.CustomerJWTClaims); ok {
47 | return *claims, nil
48 | }
49 | return cc, fmt.Errorf("error parsing jwt token")
50 | }
51 |
52 | type AuthService struct {
53 | accountRepo ports.AccountRepositorer
54 | memberRepo ports.MemberRepositorer
55 | }
56 |
57 | func NewAuthService(
58 | accountRepo ports.AccountRepositorer, memberRepo ports.MemberRepositorer) *AuthService {
59 | return &AuthService{
60 | accountRepo: accountRepo,
61 | memberRepo: memberRepo,
62 | }
63 | }
64 |
65 | func (s *AuthService) AuthenticateUserAccount(
66 | ctx context.Context, authUserId string) (models.Account, error) {
67 | account, err := s.accountRepo.FetchByAuthUserId(ctx, authUserId)
68 |
69 | if errors.Is(err, repository.ErrEmpty) {
70 | return models.Account{}, ErrAccountNotFound
71 | }
72 |
73 | if err != nil {
74 | return models.Account{}, ErrAccount
75 | }
76 |
77 | return account, nil
78 | }
79 |
80 | // AuthenticateWorkspaceMember authenticates a workspace member by verifying the existence of a member
81 | // record in the database that matches the provided workspace ID and account ID.
82 | // Each member is uniquely identified by workspace ID and account ID.
83 | func (s *AuthService) AuthenticateWorkspaceMember(
84 | ctx context.Context, workspaceId string, accountId string,
85 | ) (models.Member, error) {
86 | member, err := s.memberRepo.LookupByWorkspaceAccountId(ctx, workspaceId, accountId)
87 | if errors.Is(err, repository.ErrEmpty) {
88 | return models.Member{}, ErrMemberNotFound
89 | }
90 | if err != nil {
91 | return models.Member{}, ErrMember
92 | }
93 | return member, nil
94 | }
95 |
96 | func (s *AuthService) ValidatePersonalAccessToken(
97 | ctx context.Context, token string) (models.Account, error) {
98 | account, err := s.accountRepo.LookupByToken(ctx, token)
99 |
100 | if errors.Is(err, repository.ErrEmpty) {
101 | return models.Account{}, ErrAccountNotFound
102 | }
103 |
104 | if err != nil {
105 | return models.Account{}, ErrAccount
106 |
107 | }
108 |
109 | return account, nil
110 | }
111 |
112 | type CustomerAuthService struct {
113 | repo ports.CustomerRepositorer
114 | }
115 |
116 | func NewCustomerAuthService(repo ports.CustomerRepositorer) *CustomerAuthService {
117 | return &CustomerAuthService{
118 | repo: repo,
119 | }
120 | }
121 |
122 | func (s *CustomerAuthService) AuthenticateWorkspaceCustomer(
123 | ctx context.Context, workspaceId string, customerId string, role *string) (models.Customer, error) {
124 | customer, err := s.repo.LookupWorkspaceCustomerById(ctx, workspaceId, customerId, role)
125 | if errors.Is(err, repository.ErrEmpty) {
126 | return models.Customer{}, ErrCustomerNotFound
127 | }
128 |
129 | if err != nil {
130 | return models.Customer{}, ErrCustomer
131 | }
132 | return customer, nil
133 | }
134 |
135 | func (s *CustomerAuthService) GetWidgetLinkedSecretKey(
136 | ctx context.Context, widgetId string) (models.WorkspaceSecret, error) {
137 | sk, err := s.repo.LookupSecretKeyByWidgetId(ctx, widgetId)
138 |
139 | if errors.Is(err, repository.ErrEmpty) {
140 | return models.WorkspaceSecret{}, ErrSecretKeyNotFound
141 | }
142 |
143 | if err != nil {
144 | return models.WorkspaceSecret{}, ErrSecretKey
145 | }
146 | return sk, nil
147 | }
148 |
--------------------------------------------------------------------------------
/backend/utils/html.go:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | import (
4 | "bytes"
5 | "golang.org/x/net/html"
6 | "strings"
7 |
8 | htmltomarkdown "github.com/JohannesKaufmann/html-to-markdown/v2"
9 | )
10 |
11 | // HTMLMatcher represents the criteria used to match HTML elements for removal or processing.
12 | // Tag specifies the HTML tag to match.
13 | // Class specifies the CSS class to match.
14 | // Attributes specifies a map of attribute key-value pairs to match.
15 | type HTMLMatcher struct {
16 | Tag string
17 | Class string
18 | Attributes map[string]string
19 | }
20 |
21 | // CleanHTML removes unwanted HTML elements from the provided content based on the specified matchers.
22 | func CleanHTML(content string, matchers []HTMLMatcher) (string, error) {
23 | doc, err := html.Parse(strings.NewReader(content))
24 | if err != nil {
25 | return "", err
26 | }
27 |
28 | // Remove unwanted elements
29 | var traverse func(*html.Node)
30 |
31 | traverse = func(n *html.Node) {
32 | // Track nodes to remove
33 | var toRemove []*html.Node
34 |
35 | for c := n.FirstChild; c != nil; c = c.NextSibling {
36 | if c.Type == html.ElementNode {
37 | for _, matcher := range matchers {
38 | if shouldRemove(c, matcher) {
39 | toRemove = append(toRemove, c)
40 | }
41 | }
42 | }
43 | traverse(c)
44 | }
45 | for _, node := range toRemove {
46 | n.RemoveChild(node)
47 | }
48 | }
49 | // Start with the root
50 | traverse(doc)
51 |
52 | // Render cleaned HTML
53 | var buf bytes.Buffer
54 | err = html.Render(&buf, doc)
55 | if err != nil {
56 | return "", nil
57 | }
58 | return buf.String(), nil
59 | }
60 |
61 | func shouldRemove(n *html.Node, matcher HTMLMatcher) bool {
62 | // Safety check - if no criteria, remove nothing.
63 | if matcher.Tag == "" && matcher.Class == "" && len(matcher.Attributes) == 0 {
64 | return false
65 | }
66 |
67 | // If only Tag is specified - with no class or attributes,
68 | // do a simple tag match
69 | if matcher.Tag != "" && matcher.Class == "" && len(matcher.Attributes) == 0 {
70 | return n.Data == matcher.Tag
71 | }
72 |
73 | // Match class
74 | if matcher.Class != "" {
75 | classFound := false
76 | for _, attr := range n.Attr {
77 | if attr.Key == "class" && strings.Contains(attr.Val, matcher.Class) {
78 | classFound = true
79 | break
80 | }
81 | }
82 | if !classFound {
83 | return false
84 | }
85 | }
86 |
87 | // Match attributes
88 | if len(matcher.Attributes) > 0 {
89 | // KV
90 | for expectedKey, expectedVal := range matcher.Attributes {
91 | attrFound := false
92 | for _, attr := range n.Attr {
93 | if attr.Key == expectedKey && attr.Val == expectedVal {
94 | attrFound = true
95 | break
96 | }
97 | }
98 | if !attrFound {
99 | return false
100 | }
101 | }
102 | }
103 | return true
104 | }
105 |
106 | // DefaultHTMLMatchers returns a slice of HTMLMatcher objects with predefined criteria for matching HTML elements.
107 | // GMail is a good starting point.
108 | func DefaultHTMLMatchers() []HTMLMatcher {
109 | return []HTMLMatcher{
110 | {
111 | Class: "gmail_quote",
112 | },
113 | }
114 | }
115 |
116 | func HTMLToMarkdown(html string) (string, error) {
117 | markdown, err := htmltomarkdown.ConvertString(html)
118 | if err != nil {
119 | return "", err
120 | }
121 | return markdown, nil
122 | }
123 |
124 | func ExtractTextFromHTML(htmlContent string) (string, error) {
125 | doc, err := html.Parse(strings.NewReader(htmlContent))
126 | if err != nil {
127 | return "", err
128 | }
129 |
130 | var buf bytes.Buffer
131 | var traverse func(*html.Node)
132 |
133 | traverse = func(n *html.Node) {
134 | if n.Type == html.TextNode {
135 | // Skip if the text is just whitespace
136 | text := strings.TrimSpace(n.Data)
137 | if text != "" {
138 | buf.WriteString(text)
139 | buf.WriteString(" ")
140 | }
141 | }
142 |
143 | // Skip script and style elements, but allow code blocks
144 | if n.Type == html.ElementNode {
145 | if n.Data == "script" || n.Data == "style" {
146 | return
147 | }
148 | // Add special handling for code blocks
149 | if n.Data == "code" {
150 | buf.WriteString("\n```\n")
151 | }
152 | }
153 |
154 | // Recursively traverse child nodes
155 | for c := n.FirstChild; c != nil; c = c.NextSibling {
156 | traverse(c)
157 | }
158 |
159 | // Close code blocks and add newlines after block elements
160 | if n.Type == html.ElementNode {
161 | if n.Data == "code" {
162 | buf.WriteString("\n```\n")
163 | }
164 | switch n.Data {
165 | case "p", "div", "br", "h1", "h2", "h3", "h4", "h5", "h6", "li", "pre":
166 | buf.WriteString("\n")
167 | }
168 | }
169 | }
170 |
171 | traverse(doc)
172 | return strings.TrimSpace(buf.String()), nil
173 | }
174 |
--------------------------------------------------------------------------------