feat: new install & set-system-user-password flags for setting up DB schema and setting system user password

- fixes issues in auth redirect to login, by making simple session cookie auto create = true.
- fixes issues with conversation tab filters WS subscription.
- fixes nulls returned for empty team list in handlers
- fixes logout btn not working.
- fixes charts error when there's no data returned from the API.
- Updates schema.sql
This commit is contained in:
Abhinav Raut
2024-10-07 04:26:10 +05:30
parent 328f3fec96
commit 4f9118d675
19 changed files with 469 additions and 504 deletions

View File

@@ -5,7 +5,7 @@ VERSION := $(shell git describe --tags)
BUILDSTR := ${VERSION} (Commit: ${LAST_COMMIT_DATE} (${LAST_COMMIT}), Build: $(shell date +"%Y-%m-%d %H:%M:%S %z"))
BIN_ARTEMIS := artemis.bin
STATIC := frontend/dist i18n
STATIC := frontend/dist i18n schema.sql
GOPATH ?= $(HOME)/go
STUFFBIN ?= $(GOPATH)/bin/stuffbin

View File

@@ -82,6 +82,8 @@ func initFlags() {
f.StringSlice("config", []string{"config.toml"},
"path to one or more config files (will be merged in order)")
f.Bool("version", false, "show current version of the build")
f.Bool("install", false, "setup database")
f.Bool("set-system-user-password", false, "set password for the system user")
if err := f.Parse(os.Args[1:]); err != nil {
log.Fatalf("loading flags: %v", err)
@@ -151,7 +153,7 @@ func loadSettings(m *setting.Manager) {
}
}
func initSettingsManager(db *sqlx.DB) *setting.Manager {
func initSettings(db *sqlx.DB) *setting.Manager {
s, err := setting.New(setting.Opts{
DB: db,
Lo: initLogger("settings"),

68
cmd/install.go Normal file
View File

@@ -0,0 +1,68 @@
package main
import (
"fmt"
"log"
"strings"
"github.com/abhinavxd/artemis/internal/user"
"github.com/jmoiron/sqlx"
"github.com/knadh/stuffbin"
"github.com/lib/pq"
)
// install checks if the schema is already installed, prompts for confirmation, and installs the schema if needed.
func install(db *sqlx.DB, fs stuffbin.FileSystem) error {
installed, err := checkSchema(db)
if err != nil {
log.Fatalf("error checking db schema: %v", err)
}
if installed {
fmt.Printf("\033[31m** WARNING: This will wipe your entire DB - '%s' **\033[0m\n", ko.String("db.database"))
fmt.Print("Continue (y/n)? ")
var ok string
fmt.Scanf("%s", &ok)
if !strings.EqualFold(ok, "y") {
log.Fatalf("installation cancelled")
}
}
// Install schema.
if err := installSchema(db, fs); err != nil {
log.Fatalf("error installing schema: %v", err)
}
log.Println("Schema installed successfully")
// Create system user.
if err := user.CreateSystemUser(db); err != nil {
log.Fatalf("error creating system user: %v", err)
}
return nil
}
// setSystemUserPass prompts for pass and sets system user password.
func setSystemUserPass(db *sqlx.DB) {
user.ChangeSystemUserPassword(db)
}
// checkSchema verifies if the DB schema is already installed by querying a table.
func checkSchema(db *sqlx.DB) (bool, error) {
if _, err := db.Exec(`SELECT * FROM settings LIMIT 1`); err != nil {
if pqErr, ok := err.(*pq.Error); ok && pqErr.Code == "42P01" {
return false, nil
}
return false, err
}
return true, nil
}
// installSchema reads the schema file and installs it in the database.
func installSchema(db *sqlx.DB, fs stuffbin.FileSystem) error {
q, err := fs.Read("/schema.sql")
if err != nil {
return err
}
_, err = db.Exec(string(q))
return err
}

View File

@@ -69,18 +69,41 @@ func main() {
// Load the config files into Koanf.
initConfig(ko)
// Init stuffbin fs.
fs := initFS()
// Init DB.
db := initDB()
// Installer.
if ko.Bool("install") {
install(db, fs)
os.Exit(0)
}
if ko.Bool("set-system-user-password") {
setSystemUserPass(db)
os.Exit(0)
}
// Check if schema is installed.
installed, err := checkSchema(db)
if err != nil {
log.Fatalf("error checking db schema: %v", err)
}
if !installed {
log.Println("Database tables are missing. Use the `--install` flag to set up the database schema.")
os.Exit(0)
}
// Load app settings from DB into Koanf.
setting := initSettingsManager(db)
loadSettings(setting)
settings := initSettings(db)
loadSettings(settings)
var (
shutdownCh = make(chan struct{})
ctx, stop = signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGINT, syscall.SIGTERM)
wsHub = ws.NewHub()
fs = initFS()
i18n = initI18n(fs)
lo = initLogger("artemis")
rdb = initRedis()
@@ -127,7 +150,7 @@ func main() {
fs: fs,
i18n: i18n,
media: media,
setting: setting,
setting: settings,
contact: contact,
inbox: inbox,
user: user,

View File

@@ -36,7 +36,7 @@ func perm(handler fastglue.FastRequestHandler, obj, act string) fastglue.FastReq
return r.SendErrorEnvelope(http.StatusForbidden, "Permission denied", nil, envelope.PermissionError)
}
// Call the handler.
// Return handler.
return handler(r)
}
}
@@ -88,10 +88,10 @@ func noAuthPage(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler
return handler(r)
}
// User is logged in direct if `next` is available else redirect to the dashboard.
// User is logged in direct if `next` is available else redirect.
nextURI := string(r.RequestCtx.QueryArgs().Peek("next"))
if len(nextURI) == 0 {
nextURI = "/dashboard"
nextURI = "/"
}
return r.RedirectURI(nextURI, fasthttp.StatusFound, nil, "")

View File

@@ -73,7 +73,8 @@ const allNavLinks = ref([
const bottomLinks = ref([
{
to: '/logout',
to: '/api/logout',
isLink: false,
icon: 'lucide:log-out',
title: 'Logout'
}

View File

@@ -26,29 +26,21 @@ const getButtonVariant = (to) => {
</script>
<template>
<div
:data-collapsed="isCollapsed"
class="group flex flex-col gap-4 py-2 data-[collapsed=true]:py-2 h-full"
>
<nav
class="grid gap-1 px-2 group-[[data-collapsed=true]]:justify-center group-[[data-collapsed=true]]:px-2"
>
<div :data-collapsed="isCollapsed" class="group flex flex-col gap-4 py-2 data-[collapsed=true]:py-2 h-full">
<nav class="grid gap-1 px-2 group-[[data-collapsed=true]]:justify-center group-[[data-collapsed=true]]:px-2">
<template v-for="(link, index) of links">
<!-- Collapsed -->
<router-link :to="link.to" v-if="isCollapsed" :key="`1-${index}`">
<TooltipProvider :delay-duration="10">
<Tooltip>
<TooltipTrigger as-child>
<span
:class="
cn(
buttonVariants({ variant: getButtonVariant(link.to), size: 'icon' }),
'h-9 w-9',
link.variant === getButtonVariant(link.to) &&
'dark:bg-muted dark:text-muted-foreground dark:hover:bg-muted dark:hover:text-white'
)
"
>
<span :class="cn(
buttonVariants({ variant: getButtonVariant(link.to), size: 'icon' }),
'h-9 w-9',
link.variant === getButtonVariant(link.to) &&
'dark:bg-muted dark:text-muted-foreground dark:hover:bg-muted dark:hover:text-white'
)
">
<Icon :icon="link.icon" class="size-5" />
<span class="sr-only">{{ link.title }}</span>
</span>
@@ -64,30 +56,20 @@ const getButtonVariant = (to) => {
</router-link>
<!-- Expanded -->
<router-link
v-else
:to="link.to"
:key="`2-${index}`"
:class="
cn(
buttonVariants({ variant: getButtonVariant(link.to), size: 'sm' }),
link.variant === getButtonVariant(link.to) &&
'dark:bg-muted dark:text-white dark:hover:bg-muted dark:hover:text-white',
'justify-start'
)
"
>
<router-link v-else :to="link.to" :key="`2-${index}`" :class="cn(
buttonVariants({ variant: getButtonVariant(link.to), size: 'sm' }),
link.variant === getButtonVariant(link.to) &&
'dark:bg-muted dark:text-white dark:hover:bg-muted dark:hover:text-white',
'justify-start'
)
">
<Icon :icon="link.icon" class="mr-2 size-5" />
{{ link.title }}
<span
v-if="link.label"
:class="
cn(
'ml-',
link.variant === getButtonVariant(link.to) && 'text-background dark:text-white'
)
"
>
<span v-if="link.label" :class="cn(
'ml-',
link.variant === getButtonVariant(link.to) && 'text-background dark:text-white'
)
">
{{ link.label }}
</span>
</router-link>
@@ -101,24 +83,20 @@ const getButtonVariant = (to) => {
<TooltipProvider :delay-duration="10">
<Tooltip>
<TooltipTrigger as-child>
<router-link
:to="bottomLink.to"
:class="
cn(
buttonVariants({
variant: getButtonVariant(bottomLink.to),
size: isCollapsed ? 'icon' : 'sm'
}),
bottomLink.variant === getButtonVariant(bottomLink.to) &&
'dark:bg-muted dark:text-white dark:hover:bg-muted dark:hover:text-white',
'justify-start'
)
"
>
<a :href="bottomLink.to" :class="cn(
buttonVariants({
variant: getButtonVariant(bottomLink.to),
size: isCollapsed ? 'icon' : 'sm'
}),
bottomLink.variant === getButtonVariant(bottomLink.to) &&
'dark:bg-muted dark:text-white dark:hover:bg-muted dark:hover:text-white',
'justify-start'
)
">
<Icon :icon="bottomLink.icon" class="mr-2 size-5" v-if="!isCollapsed" />
<span v-if="!isCollapsed">{{ bottomLink.title }}</span>
<Icon :icon="bottomLink.icon" class="size-5 mx-auto" v-else />
</router-link>
</a>
</TooltipTrigger>
<TooltipContent side="right" class="flex items-center gap-4">
{{ bottomLink.title }}

View File

@@ -4,7 +4,7 @@
<Tabs v-model="conversationType">
<TabsList class="w-full flex justify-evenly">
<TabsTrigger value="assigned" class="w-full"> Assigned </TabsTrigger>
<TabsTrigger value="unassigned" class="w-full"> Unassigned </TabsTrigger>
<TabsTrigger value="team" class="w-full"> Team </TabsTrigger>
<TabsTrigger value="all" class="w-full"> All </TabsTrigger>
</TabsList>
</Tabs>

View File

@@ -18,7 +18,6 @@ import { LineChart } from '@/components/ui/chart-line'
const props = defineProps({
data: {
type: Array,
required: true,
default: () => []
}
})

View File

@@ -8,6 +8,6 @@ export const CONVERSATION_FILTERS = {
export const CONVERSATION_LIST_TYPE = {
ASSIGNED: 'assigned',
UNASSIGNED: 'unassigned',
UNASSIGNED: 'team',
ALL: 'all'
}

View File

@@ -69,7 +69,8 @@ const getCardStats = () => {
const getDashboardCharts = () => {
return api.getGlobalDashboardCharts()
.then((resp) => {
chartData.value = resp.data.data
chartData.value.new_conversations = resp.data.data.new_conversations || []
chartData.value.status_summary = resp.data.data.status_summary || []
})
.catch((err) => {
toast({

View File

@@ -96,7 +96,7 @@ const redirectToOIDC = (provider) => {
}
const validateForm = () => {
if (!validateEmail(loginForm.value.email)) {
if (!validateEmail(loginForm.value.email) && loginForm.value.email !== 'System') {
errorMessage.value = 'Invalid email address.'
useTemporaryClass('login-container', 'animate-shake')
return false
@@ -144,7 +144,10 @@ const enabledOIDCProviders = computed(() => {
return oidcProviders.value.filter((provider) => !provider.disabled)
})
const emailHasError = computed(() => !validateEmail(loginForm.value.email) && loginForm.value.email !== '')
const emailHasError = computed(() => {
const email = loginForm.value.email;
return email !== 'System' && !validateEmail(email) && email !== '';
})
const passwordHasError = computed(() => !loginForm.value.password && loginForm.value.password !== '')
</script>

View File

@@ -75,12 +75,12 @@ func New(cfg Config, rd *redis.Client, logger *logf.Logger) (*Auth, error) {
}
sess := simplesessions.New(simplesessions.Options{
EnableAutoCreate: false,
EnableAutoCreate: true,
SessionIDLength: 64,
Cookie: simplesessions.CookieOptions{
IsHTTPOnly: true,
IsSecure: true,
MaxAge: time.Hour * 12,
MaxAge: time.Hour * 6,
},
})
@@ -166,12 +166,14 @@ func (a *Auth) SaveSession(user models.User, r *fastglue.Request) error {
func (a *Auth) ValidateSession(r *fastglue.Request) (models.User, error) {
sess, err := a.sess.Acquire(r.RequestCtx, r, r)
if err != nil {
a.logger.Error("error acquiring session", "error", err)
return models.User{}, err
}
// Get the session variables
sessVals, err := sess.GetMulti("id", "email", "first_name", "last_name")
if err != nil {
a.logger.Error("error fetching session variables", "error", err)
return models.User{}, err
}
@@ -184,7 +186,6 @@ func (a *Auth) ValidateSession(r *fastglue.Request) (models.User, error) {
// Logged in?
if userID <= 0 {
a.logger.Error("error fetching session", "error", err)
return models.User{}, err
}
@@ -203,7 +204,7 @@ func (a *Auth) DestroySession(r *fastglue.Request) error {
a.logger.Error("error acquiring session", "error", err)
return err
}
if err := sess.Destroy(); err != nil {
if err := sess.Clear(); err != nil {
a.logger.Error("error clearing session", "error", err)
return err
}

View File

@@ -322,22 +322,8 @@ SELECT
m.uuid,
m.private,
m.sender_type,
u.uuid as sender_uuid,
COALESCE(
json_agg(
json_build_object(
'name', a.filename,
'content_type', a.content_type,
'uuid', a.uuid,
'size', a.size
) ORDER BY a.filename
) FILTER (WHERE a.message_id IS NOT NULL),
'[]'::json
) AS attachments
u.uuid as sender_uuid
FROM messages m
LEFT JOIN attachments a
ON a.message_id = m.id
AND a.content_disposition = 'attachment'
LEFT JOIN users u on u.id = m.sender_id
WHERE m.uuid = $1
GROUP BY

View File

@@ -103,7 +103,6 @@ func (s *Service) worker(ctx context.Context) {
for {
select {
case <-ctx.Done():
s.lo.Info("worker exiting due to context cancellation")
return
case message := <-s.messageChannel:
sender, exists := s.providers[message.Provider]

View File

@@ -52,7 +52,7 @@ func New(opts Opts) (*Manager, error) {
// GetAll retrieves all tags.
func (t *Manager) GetAll() ([]models.Tag, error) {
var tags []models.Tag
var tags = make([]models.Tag, 0)
if err := t.q.GetAllTags.Select(&tags); err != nil {
t.lo.Error("error fetching tags", "error", err)
return nil, envelope.NewError(envelope.GeneralError, "Error fetching tags", nil)

View File

@@ -59,9 +59,9 @@ func New(opts Opts) (*Manager, error) {
// GetAll retrieves all teams.
func (u *Manager) GetAll() ([]models.Team, error) {
var teams []models.Team
var teams = make([]models.Team, 0)
if err := u.q.GetTeams.Select(&teams); err != nil {
if errors.Is(sql.ErrNoRows, err) {
if errors.Is(err, sql.ErrNoRows) {
return teams, nil
}
u.lo.Error("error fetching teams from db", "error", err)
@@ -74,7 +74,7 @@ func (u *Manager) GetAll() ([]models.Team, error) {
func (u *Manager) GetTeam(id int) (models.Team, error) {
var team models.Team
if err := u.q.GetTeam.Get(&team, id); err != nil {
if errors.Is(sql.ErrNoRows, err) {
if errors.Is(err, sql.ErrNoRows) {
u.lo.Error("team not found", "id", id, "error", err)
return team, nil
}

View File

@@ -6,6 +6,7 @@ import (
"embed"
"errors"
"fmt"
"regexp"
"strings"
"github.com/abhinavxd/artemis/internal/dbutil"
@@ -30,7 +31,9 @@ var (
)
const (
SystemUserUUID = "00000000-0000-0000-0000-000000000000"
SystemUserEmail = "System"
MinSystemUserPasswordLen = 8
MaxSystemUserPasswordLen = 50
)
// Manager handles user-related operations.
@@ -48,15 +51,15 @@ type Opts struct {
// queries contains prepared SQL queries.
type queries struct {
CreateUser *sqlx.Stmt `query:"create-user"`
GetUsers *sqlx.Stmt `query:"get-users"`
GetUser *sqlx.Stmt `query:"get-user"`
GetEmail *sqlx.Stmt `query:"get-email"`
GetPermissions *sqlx.Stmt `query:"get-permissions"`
GetUserByEmail *sqlx.Stmt `query:"get-user-by-email"`
UpdateUser *sqlx.Stmt `query:"update-user"`
UpdateAvatar *sqlx.Stmt `query:"update-avatar"`
SetUserPassword *sqlx.Stmt `query:"set-user-password"`
CreateUser *sqlx.Stmt `query:"create-user"`
GetUsers *sqlx.Stmt `query:"get-users"`
GetUser *sqlx.Stmt `query:"get-user"`
GetEmail *sqlx.Stmt `query:"get-email"`
GetPermissions *sqlx.Stmt `query:"get-permissions"`
GetUserByEmail *sqlx.Stmt `query:"get-user-by-email"`
UpdateUser *sqlx.Stmt `query:"update-user"`
UpdateAvatar *sqlx.Stmt `query:"update-avatar"`
SetUserPassword *sqlx.Stmt `query:"set-user-password"`
}
// New creates and returns a new instance of the Manager.
@@ -79,7 +82,7 @@ func (u *Manager) Login(email string, password []byte) (models.User, error) {
var user models.User
if err := u.q.GetUserByEmail.Get(&user, email); err != nil {
if errors.Is(sql.ErrNoRows, err) {
if errors.Is(err, sql.ErrNoRows) {
return user, envelope.NewError(envelope.InputError, u.i18n.T("user.invalidEmailPassword"), nil)
}
u.lo.Error("error fetching user from db", "error", err)
@@ -97,7 +100,7 @@ func (u *Manager) Login(email string, password []byte) (models.User, error) {
func (u *Manager) GetUsers() ([]models.User, error) {
var users []models.User
if err := u.q.GetUsers.Select(&users); err != nil {
if errors.Is(sql.ErrNoRows, err) {
if errors.Is(err, sql.ErrNoRows) {
return users, nil
}
u.lo.Error("error fetching users from db", "error", err)
@@ -156,7 +159,7 @@ func (u *Manager) GetByEmail(email string) (models.User, error) {
// GetSystemUser retrieves the system user.
func (u *Manager) GetSystemUser() (models.User, error) {
return u.Get(0, SystemUserUUID)
return u.GetByEmail(SystemUserEmail)
}
// UpdateAvatar updates the user avatar.
@@ -208,22 +211,6 @@ func (u *Manager) verifyPassword(pwd []byte, pwdHash string) error {
return nil
}
// setPassword sets a new password for a user.
func (u *Manager) setPassword(uid int, pwd string) error {
if len(pwd) > 72 {
return ErrPasswordTooLong
}
bytes, err := bcrypt.GenerateFromPassword([]byte(pwd), 12)
if err != nil {
return err
}
if _, err := u.q.SetUserPassword.Exec(bytes, uid); err != nil {
u.lo.Error("setting password", "error", err)
return fmt.Errorf("error setting password")
}
return nil
}
// generatePassword generates a random password and returns its bcrypt hash.
func (u *Manager) generatePassword() ([]byte, error) {
password, _ := stringutil.RandomAlNumString(16)
@@ -234,3 +221,77 @@ func (u *Manager) generatePassword() ([]byte, error) {
}
return bytes, nil
}
// CreateSystemUser inserts a default system user into the users table with the prompted password.
func CreateSystemUser(db *sqlx.DB) error {
// Prompt for password and get hashed password
hashedPassword, err := promptAndHashPassword()
if err != nil {
return err
}
_, err = db.Exec(`
INSERT INTO users (email, first_name, last_name, password, roles)
VALUES ($1, $2, $3, $4, $5)`,
SystemUserEmail, "System", "", hashedPassword, pq.StringArray{"Admin"})
if err != nil {
return fmt.Errorf("failed to create system user: %v", err)
}
fmt.Println("System user created successfully")
return nil
}
// ChangeSystemUserPassword updates the system user's password with a newly prompted one.
func ChangeSystemUserPassword(db *sqlx.DB) error {
// Prompt for password and get hashed password
hashedPassword, err := promptAndHashPassword()
if err != nil {
return err
}
// Update system user's password in the database.
if err := updateSystemUserPassword(db, hashedPassword); err != nil {
return fmt.Errorf("error updating system user password: %v", err)
}
fmt.Println("System user password updated successfully.")
return nil
}
// promptAndHashPassword handles password input and validation, and returns the hashed password.
func promptAndHashPassword() ([]byte, error) {
var password string
for {
fmt.Print("Please set System admin password (min 8, max 50 characters, at least 1 uppercase letter, 1 number): ")
fmt.Scanf("%s", &password)
if isValidSystemUserPassword(password) {
break
}
fmt.Println("Password does not meet the strength requirements.")
}
// Hash the password using bcrypt.
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return nil, fmt.Errorf("failed to hash password: %v", err)
}
return hashedPassword, nil
}
// updateSystemUserPassword updates the password of the system user in the database.
func updateSystemUserPassword(db *sqlx.DB, hashedPassword []byte) error {
_, err := db.Exec(`UPDATE users SET password = $1 WHERE email = $2`, hashedPassword, SystemUserEmail)
if err != nil {
return fmt.Errorf("failed to update system user password: %v", err)
}
return nil
}
// isValidSystemUserPassword checks if the password meets the required strength for system user.
func isValidSystemUserPassword(password string) bool {
if len(password) < MinSystemUserPasswordLen || len(password) > MaxSystemUserPasswordLen {
return false
}
hasUppercase := regexp.MustCompile(`[A-Z]`).MatchString(password)
hasNumber := regexp.MustCompile(`[0-9]`).MatchString(password)
return hasUppercase && hasNumber
}

View File

@@ -1,442 +1,285 @@
-- public.attachments definition
DROP TYPE IF EXISTS "channels" CASCADE; CREATE TYPE "channels" AS ENUM ('email');
DROP TYPE IF EXISTS "media_store" CASCADE; CREATE TYPE "media_store" AS ENUM ('s3', 'localfs');
DROP TYPE IF EXISTS "message_type" CASCADE; CREATE TYPE "message_type" AS ENUM ('incoming','outgoing','activity');
-- Drop table
-- DROP TABLE public.attachments;
CREATE TABLE public.attachments (
id int8 DEFAULT nextval('media_id_seq'::regclass) NOT NULL,
"uuid" uuid DEFAULT gen_random_uuid() NULL,
created_at timestamp DEFAULT now() NULL,
store varchar(10) DEFAULT ''::text NOT NULL,
filename varchar(140) NOT NULL,
content_type varchar(140) NOT NULL,
message_id int8 NULL,
"size" varchar(10) NULL,
content_disposition varchar(50) NULL,
CONSTRAINT media_pkey PRIMARY KEY (id)
);
-- public.automation_rules definition
-- Drop table
-- DROP TABLE public.automation_rules;
CREATE TABLE public.automation_rules (
id int4 DEFAULT nextval('rules_id_seq'::regclass) NOT NULL,
"name" varchar(255) NOT NULL,
description text NULL,
created_at timestamp DEFAULT now() NOT NULL,
DROP TABLE IF EXISTS automation_rules CASCADE;
CREATE TABLE automation_rules (
id SERIAL PRIMARY KEY,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now(),
"name" VARCHAR(255) NOT NULL,
description TEXT NULL,
"type" varchar NOT NULL,
rules jsonb NULL,
updated_at timestamp DEFAULT now() NOT NULL,
disabled bool DEFAULT false NOT NULL,
CONSTRAINT rules_pkey PRIMARY KEY (id)
CONSTRAINT constraint_automation_rules_on_name CHECK (length("name") <= 100),
CONSTRAINT constraint_automation_rules_on_description CHECK (length(description) <= 300)
);
-- public.canned_responses definition
-- Drop table
-- DROP TABLE public.canned_responses;
CREATE TABLE public.canned_responses (
id serial4 NOT NULL,
created_at timestamp DEFAULT CURRENT_TIMESTAMP NULL,
updated_at timestamp DEFAULT CURRENT_TIMESTAMP NULL,
title text NOT NULL,
"content" text NOT NULL,
CONSTRAINT canned_responses_pkey PRIMARY KEY (id)
DROP TABLE IF EXISTS canned_responses CASCADE;
CREATE TABLE canned_responses (
id SERIAL PRIMARY KEY,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now(),
title TEXT NOT NULL,
"content" TEXT NOT NULL,
CONSTRAINT constraint_canned_responses_on_title CHECK (length(title) <= 100),
CONSTRAINT constraint_canned_responses_on_content CHECK (length("content") <= 5000)
);
-- public.contacts definition
-- Drop table
-- DROP TABLE public.contacts;
CREATE TABLE public.contacts (
id bigserial NOT NULL,
created_at timestamptz DEFAULT CURRENT_TIMESTAMP NULL,
updated_at timestamptz DEFAULT CURRENT_TIMESTAMP NULL,
DROP TABLE IF EXISTS contacts CASCADE;
CREATE TABLE contacts (
id BIGSERIAL PRIMARY KEY,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now(),
"uuid" uuid DEFAULT gen_random_uuid() NOT NULL,
first_name text NULL,
last_name text NULL,
email varchar NULL,
phone_number text NULL,
avatar_url text NULL,
inbox_id int4 NULL,
source_id text NULL,
CONSTRAINT contacts_pkey PRIMARY KEY (id)
first_name TEXT NULL,
last_name TEXT NULL,
email VARCHAR(254) NULL,
phone_number TEXT NULL,
avatar_url TEXT NULL,
inbox_id INT NULL,
source_id TEXT NULL,
CONSTRAINT constraint_contacts_on_first_name CHECK (length(first_name) <= 100),
CONSTRAINT constraint_contacts_on_last_name CHECK (length(last_name) <= 100),
CONSTRAINT constraint_contacts_on_email CHECK (length(email) <= 254),
CONSTRAINT constraint_contacts_on_phone_number CHECK (length(phone_number) <= 50),
CONSTRAINT constraint_contacts_on_avatar_url CHECK (length(avatar_url) <= 1000),
CONSTRAINT constraint_contacts_on_source_id CHECK (length(source_id) <= 5000)
);
-- public.conversation_participants definition
-- Drop table
-- DROP TABLE public.conversation_participants;
CREATE TABLE public.conversation_participants (
id bigserial NOT NULL,
created_at timestamptz DEFAULT CURRENT_TIMESTAMP NULL,
updated_at timestamptz DEFAULT CURRENT_TIMESTAMP NULL,
user_id int8 NULL,
conversation_id int8 NULL,
CONSTRAINT conversation_participants_pkey PRIMARY KEY (id),
CONSTRAINT conversation_participants_unique UNIQUE (conversation_id, user_id)
DROP TABLE IF EXISTS conversation_participants CASCADE;
CREATE TABLE conversation_participants (
id BIGSERIAL PRIMARY KEY,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now(),
user_id BIGINT NULL,
conversation_id BIGINT NULL,
CONSTRAINT constraint_conversation_participants_conversation_id_and_user_id_unique UNIQUE (conversation_id, user_id)
);
-- public.file_upload_providers definition
-- Drop table
-- DROP TABLE public.file_upload_providers;
CREATE TABLE public.file_upload_providers (
id serial4 NOT NULL,
provider_name text NOT NULL,
region text NULL,
access_key text NULL,
access_secret text NULL,
bucket_name text NULL,
bucket_type text NULL,
bucket_path text NULL,
upload_expiry interval NULL,
s3_backend_url text NULL,
custom_public_url text NULL,
upload_path text NULL,
upload_uri text NULL,
created_at timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL,
CONSTRAINT file_upload_providers_pkey PRIMARY KEY (id)
);
-- public.inboxes definition
-- Drop table
-- DROP TABLE public.inboxes;
CREATE TABLE public.inboxes (
id serial4 NOT NULL,
created_at timestamp DEFAULT CURRENT_TIMESTAMP NULL,
updated_at timestamp DEFAULT CURRENT_TIMESTAMP NULL,
channel public."channels" NOT NULL,
DROP TABLE IF EXISTS inboxes CASCADE;
CREATE TABLE inboxes (
id SERIAL PRIMARY KEY,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now(),
channel "channels" NOT NULL,
disabled bool DEFAULT false NOT NULL,
config jsonb DEFAULT '{}'::jsonb NOT NULL,
"name" varchar(140) NOT NULL,
"from" varchar(200) NULL,
assign_to_team int4 NULL,
soft_delete bool DEFAULT false NOT NULL,
CONSTRAINT inboxes_pkey PRIMARY KEY (id)
"name" VARCHAR(140) NOT NULL,
"from" VARCHAR(300) NULL,
assign_to_team INT NULL,
soft_delete bool DEFAULT false NOT NULL
);
-- public.media definition
-- Drop table
-- DROP TABLE public.media;
CREATE TABLE public.media (
id serial4 NOT NULL,
created_at timestamp DEFAULT now() NOT NULL,
DROP TABLE IF EXISTS media CASCADE;
CREATE TABLE media (
id SERIAL PRIMARY KEY,
created_at TIMESTAMPTZ DEFAULT now(),
"uuid" uuid DEFAULT gen_random_uuid() NOT NULL,
store public."media_store" NOT NULL,
filename text NOT NULL,
content_type text NOT NULL,
model_id int4 NULL,
model_type text NULL,
"size" int4 NULL,
store "media_store" NOT NULL,
filename TEXT NOT NULL,
content_type TEXT NOT NULL,
model_id INT NULL,
model_type TEXT NULL,
"size" INT NULL,
meta jsonb DEFAULT '{}'::jsonb NOT NULL,
CONSTRAINT media_pkey1 PRIMARY KEY (id)
CONSTRAINT constraint_media_on_filename CHECK (length(filename) <= 1000)
);
-- public.oidc definition
-- Drop table
-- DROP TABLE public.oidc;
CREATE TABLE public.oidc (
id int4 DEFAULT nextval('social_login_id_seq'::regclass) NOT NULL,
provider_url text NOT NULL,
client_id text NOT NULL,
client_secret text NOT NULL,
DROP TABLE IF EXISTS oidc CASCADE;
CREATE TABLE oidc (
id SERIAL PRIMARY KEY,
provider_url TEXT NOT NULL,
client_id TEXT NOT NULL,
client_secret TEXT NOT NULL,
disabled bool DEFAULT false NOT NULL,
created_at timestamp DEFAULT now() NOT NULL,
updated_at timestamp DEFAULT now() NOT NULL,
provider varchar NULL,
"name" text NULL,
CONSTRAINT social_login_pkey PRIMARY KEY (id)
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now(),
provider VARCHAR NULL,
"name" TEXT NULL
);
-- public.priority definition
-- Drop table
-- DROP TABLE public.priority;
CREATE TABLE public.priority (
id serial4 NOT NULL,
"name" text NOT NULL,
created_at timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL,
CONSTRAINT priority_pkey PRIMARY KEY (id),
CONSTRAINT priority_priority_name_key UNIQUE (name)
DROP TABLE IF EXISTS priority CASCADE;
CREATE TABLE priority (
id SERIAL PRIMARY KEY,
"name" TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT now(),
CONSTRAINT constraint_priority_on_name_unique UNIQUE ("name")
);
-- public.roles definition
-- Drop table
-- DROP TABLE public.roles;
CREATE TABLE public.roles (
id serial4 NOT NULL,
permissions _text DEFAULT '{}'::text[] NOT NULL,
"name" text NULL,
description text NULL,
created_at timestamptz DEFAULT now() NULL,
updated_at timestamptz DEFAULT now() NULL,
CONSTRAINT roles_pkey PRIMARY KEY (id)
DROP TABLE IF EXISTS roles CASCADE;
CREATE TABLE roles (
id SERIAL PRIMARY KEY,
permissions _text DEFAULT '{}'::text [] NOT NULL,
"name" TEXT NULL,
description TEXT NULL,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
-- Create roles.
INSERT INTO roles
(permissions, "name", description)
VALUES('{conversations:read_team,conversations:read_all,conversations:read,conversations:read_assigned,conversations:update_user_assignee,conversations:update_team_assignee,conversations:update_priority,conversations:update_status,conversations:update_tags,messages:read,messages:write,templates:write,templates:read,roles:delete,roles:write,roles:read,inboxes:delete,inboxes:write,inboxes:read,automations:write,automations:delete,automations:read,teams:write,teams:read,users:write,users:read,dashboard_global:read,canned_responses:delete,tags:delete,canned_responses:write,tags:write,status:delete,status:write,status:read,oidc:delete,oidc:read,oidc:write,settings_notifications:read,settings_notifications:write,settings_general:write,templates:delete}', 'Admin', 'Role for users who have access to the admin panel.');
INSERT INTO roles
(permissions, "name", description)
VALUES('{conversations:read,conversations:read_team,conversations:read_assigned,conversations:update_user_assignee,conversations:update_team_assignee,conversations:update_priority,conversations:update_status,conversations:update_tags,status:write,status:delete,tags:write,tags:delete,canned_responses:write,canned_responses:delete,dashboard:global,users:write,users:read,teams:read,teams:write,automations:read,automations:write,automations:delete,inboxes:read,inboxes:write,inboxes:delete,roles:read,roles:write,roles:delete,templates:read,templates:write,messages:read,messages:write,dashboard_global:read,oidc:delete,status:read,oidc:write,settings_notifications:read,oidc:read,settings_general:write,settings_notifications:write,conversations:read_all,templates:delete}', 'Agent', 'Role for all agents with limited access.');
-- public.settings definition
-- Drop table
-- DROP TABLE public.settings;
CREATE TABLE public.settings (
"key" text NOT NULL,
DROP TABLE IF EXISTS settings CASCADE;
CREATE TABLE settings (
"key" TEXT NOT NULL,
value jsonb DEFAULT '{}'::jsonb NOT NULL,
updated_at timestamptz DEFAULT now() NULL,
CONSTRAINT settings_key_key UNIQUE (key)
updated_at TIMESTAMPTZ DEFAULT now(),
CONSTRAINT settings_key_key UNIQUE ("key")
);
CREATE INDEX idx_settings_key ON public.settings USING btree (key);
CREATE INDEX index_settings_on_key ON settings USING btree ("key");
-- public.status definition
-- Drop table
-- DROP TABLE public.status;
CREATE TABLE public.status (
id serial4 NOT NULL,
"name" text NOT NULL,
created_at timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL,
CONSTRAINT status_pkey PRIMARY KEY (id),
CONSTRAINT status_status_name_key UNIQUE (name)
DROP TABLE IF EXISTS status CASCADE;
CREATE TABLE status (
id SERIAL PRIMARY KEY,
"name" TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT now(),
CONSTRAINT constraint_status_on_name_unique UNIQUE ("name")
);
-- public.tags definition
-- Drop table
-- DROP TABLE public.tags;
CREATE TABLE public.tags (
id bigserial NOT NULL,
"name" text NOT NULL,
created_at timestamp DEFAULT CURRENT_TIMESTAMP NULL,
CONSTRAINT tags_pkey PRIMARY KEY (id),
CONSTRAINT tags_tag_name_key UNIQUE (name)
DROP TABLE IF EXISTS tags CASCADE;
CREATE TABLE tags (
id BIGSERIAL PRIMARY KEY,
"name" TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT now(),
CONSTRAINT constraint_tags_on_name_unique UNIQUE ("name")
);
-- public.team_members definition
-- Drop table
-- DROP TABLE public.team_members;
CREATE TABLE public.team_members (
id serial4 NOT NULL,
created_at timestamp DEFAULT CURRENT_TIMESTAMP NULL,
updated_at timestamp DEFAULT CURRENT_TIMESTAMP NULL,
team_id int4 NOT NULL,
user_id int4 NOT NULL,
CONSTRAINT team_members_pkey PRIMARY KEY (id),
CONSTRAINT unique_team_user UNIQUE (team_id, user_id)
DROP TABLE IF EXISTS team_members CASCADE;
CREATE TABLE team_members (
id SERIAL PRIMARY KEY,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now(),
team_id INT NOT NULL,
user_id INT NOT NULL,
CONSTRAINT constraint_team_members_on_team_id_and_user_id_unique UNIQUE (team_id, user_id)
);
-- public.teams definition
-- Drop table
-- DROP TABLE public.teams;
CREATE TABLE public.teams (
id serial4 NOT NULL,
created_at timestamp DEFAULT CURRENT_TIMESTAMP NULL,
updated_at timestamp DEFAULT CURRENT_TIMESTAMP NULL,
"name" varchar(140) NOT NULL,
DROP TABLE IF EXISTS teams CASCADE;
CREATE TABLE teams (
id SERIAL PRIMARY KEY,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now(),
"name" VARCHAR(140) NOT NULL,
disabled bool DEFAULT false NOT NULL,
auto_assign_conversations bool DEFAULT false NOT NULL,
CONSTRAINT teams_pkey PRIMARY KEY (id),
CONSTRAINT teams_unique UNIQUE (name)
CONSTRAINT constraint_teams_on_name_unique UNIQUE ("name")
);
-- public.templates definition
-- Drop table
-- DROP TABLE public.templates;
CREATE TABLE public.templates (
id serial4 NOT NULL,
body text NOT NULL,
DROP TABLE IF EXISTS templates CASCADE;
CREATE TABLE templates (
id SERIAL PRIMARY KEY,
body TEXT NOT NULL,
is_default bool DEFAULT false NOT NULL,
created_at timestamptz DEFAULT now() NULL,
updated_at timestamptz DEFAULT now() NULL,
"name" text NULL,
CONSTRAINT email_templates_pkey PRIMARY KEY (id)
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now(),
"name" TEXT NULL
);
CREATE UNIQUE INDEX email_templates_is_default_idx ON public.templates USING btree (is_default) WHERE (is_default = true);
CREATE UNIQUE INDEX unique_index_templates_on_is_default_when_is_default_is_true ON templates USING btree (is_default)
WHERE (is_default = true);
-- public.uploads definition
-- Drop table
-- DROP TABLE public.uploads;
CREATE TABLE public.uploads (
id uuid DEFAULT gen_random_uuid() NOT NULL,
filename text NOT NULL,
created_at timestamp DEFAULT CURRENT_TIMESTAMP NULL,
updated_at timestamp DEFAULT CURRENT_TIMESTAMP NULL,
CONSTRAINT uploads_pkey PRIMARY KEY (id)
);
-- public.users definition
-- Drop table
-- DROP TABLE public.users;
CREATE TABLE public.users (
id int4 DEFAULT nextval('agents_id_seq'::regclass) NOT NULL,
created_at timestamp DEFAULT CURRENT_TIMESTAMP NULL,
updated_at timestamp DEFAULT CURRENT_TIMESTAMP NULL,
email varchar(255) NOT NULL,
DROP TABLE IF EXISTS users CASCADE;
CREATE TABLE users (
id SERIAL PRIMARY KEY,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now(),
email VARCHAR(254) NOT NULL,
"uuid" uuid DEFAULT gen_random_uuid() NOT NULL,
first_name varchar(100) NOT NULL,
last_name varchar(100) NULL,
"password" varchar(150) NULL,
first_name VARCHAR(100) NOT NULL,
last_name VARCHAR(100) NULL,
"password" VARCHAR(150) NULL,
disabled bool DEFAULT false NOT NULL,
avatar_url text NULL,
roles _text DEFAULT '{}'::text[] NOT NULL,
CONSTRAINT agents_pkey PRIMARY KEY (id),
CONSTRAINT users_email_unique UNIQUE (email)
avatar_url TEXT NULL,
roles _text DEFAULT '{}'::text [] NOT NULL,
CONSTRAINT constraint_users_on_email_unique UNIQUE (email)
);
-- public.contact_methods definition
-- Drop table
-- DROP TABLE public.contact_methods;
CREATE TABLE public.contact_methods (
id bigserial NOT NULL,
contact_id int8 NOT NULL,
"source" text NOT NULL,
source_id text NOT NULL,
inbox_id int4 NULL,
created_at timestamp DEFAULT CURRENT_TIMESTAMP NULL,
updated_at timestamp DEFAULT CURRENT_TIMESTAMP NULL,
CONSTRAINT contact_methods_pkey PRIMARY KEY (id),
CONSTRAINT unique_contact_method UNIQUE (source, source_id),
CONSTRAINT fk_contact FOREIGN KEY (contact_id) REFERENCES public.contacts(id) ON DELETE CASCADE
DROP TABLE IF EXISTS contact_methods CASCADE;
CREATE TABLE contact_methods (
id BIGSERIAL PRIMARY KEY,
contact_id BIGINT REFERENCES contacts(id) ON DELETE CASCADE ON UPDATE CASCADE,
"source" TEXT NOT NULL,
source_id TEXT NOT NULL,
inbox_id INT NULL,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now(),
CONSTRAINT constraint_contact_methods_on_source_and_source_id_unique UNIQUE (contact_id, source_id)
);
-- public.conversations definition
-- Drop table
-- DROP TABLE public.conversations;
CREATE TABLE public.conversations (
id bigserial NOT NULL,
created_at timestamp DEFAULT CURRENT_TIMESTAMP NULL,
updated_at timestamp DEFAULT CURRENT_TIMESTAMP NULL,
DROP TABLE IF EXISTS conversations CASCADE;
CREATE TABLE conversations (
id BIGSERIAL PRIMARY KEY,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now(),
"uuid" uuid DEFAULT gen_random_uuid() NOT NULL,
reference_number text NOT NULL,
closed_at timestamp NULL,
contact_id int8 NOT NULL,
assigned_user_id int8 NULL,
assigned_team_id int8 NULL,
resolved_at timestamp NULL,
inbox_id int4 NOT NULL,
reference_number TEXT NOT NULL,
contact_id BIGINT NOT NULL,
assigned_user_id BIGINT NULL,
assigned_team_id BIGINT NULL,
inbox_id INT NOT NULL,
meta jsonb DEFAULT '{}'::json NOT NULL,
assignee_last_seen_at timestamptz DEFAULT CURRENT_TIMESTAMP NULL,
first_reply_at timestamp NULL,
status_id int8 DEFAULT 1 NOT NULL,
priority_id int8 NULL,
CONSTRAINT messages_pkey PRIMARY KEY (id),
CONSTRAINT conversations_status_id_fkey FOREIGN KEY (status_id) REFERENCES public.status(id)
assignee_last_seen_at TIMESTAMPTZ DEFAULT now(),
first_reply_at TIMESTAMPTZ NULL,
closed_at TIMESTAMPTZ NULL,
resolved_at TIMESTAMPTZ NULL,
status_id int REFERENCES status(id),
priority_id int REFERENCES priority(id)
);
-- public.messages definition
-- Drop table
-- DROP TABLE public.messages;
CREATE TABLE public.messages (
id bigserial NOT NULL,
updated_at timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL,
DROP TABLE IF EXISTS messages CASCADE;
CREATE TABLE messages (
id BIGSERIAL PRIMARY KEY,
updated_at TIMESTAMPTZ DEFAULT now(),
"uuid" uuid DEFAULT gen_random_uuid() NOT NULL,
"type" text NOT NULL,
status text NULL,
conversation_id bigserial NOT NULL,
"content" text NULL,
sender_id int4 NULL,
"type" TEXT NOT NULL,
status TEXT NULL,
conversation_id BIGSERIAL REFERENCES conversations(id),
"content" TEXT NULL,
sender_id INT NULL,
private bool NULL,
content_type varchar(50) DEFAULT false NOT NULL,
source_id text NULL,
content_type TEXT,
source_id TEXT NOT NULL,
meta jsonb DEFAULT '{}'::jsonb NULL,
inbox_id int4 NULL,
inbox_id INT NULL,
sender_type varchar NULL,
created_at timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL,
CONSTRAINT messages__pkey PRIMARY KEY (id),
CONSTRAINT messages_unique UNIQUE (source_id),
CONSTRAINT fk_conversation_id FOREIGN KEY (conversation_id) REFERENCES public.conversations(id)
created_at TIMESTAMPTZ DEFAULT now(),
CONSTRAINT constraint_messages_on_source_id_unique UNIQUE (source_id),
CONSTRAINT constraint_messages_on_content_type CHECK (length(content_type) <= 50)
);
DROP TABLE IF EXISTS conversation_tags CASCADE;
CREATE TABLE conversation_tags (
id BIGSERIAL PRIMARY KEY,
tag_id BIGSERIAL REFERENCES tags(id),
conversation_id BIGSERIAL REFERENCES conversations(id),
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now(),
CONSTRAINT constraint_conversation_tags_on_conversation_id_and_tag_id_unique UNIQUE (conversation_id, tag_id)
);
-- public.conversation_tags definition
-- Drop table
-- DROP TABLE public.conversation_tags;
CREATE TABLE public.conversation_tags (
id bigserial NOT NULL,
conversation_id int8 DEFAULT nextval('conversation_tags_converastion_id_seq'::regclass) NOT NULL,
tag_id bigserial NOT NULL,
created_at timestamp DEFAULT CURRENT_TIMESTAMP NULL,
CONSTRAINT conversation_tags_unique UNIQUE (conversation_id, tag_id),
CONSTRAINT message_tags_pkey PRIMARY KEY (id),
CONSTRAINT message_tags_conversation_id_fkey FOREIGN KEY (conversation_id) REFERENCES public.conversations(id),
CONSTRAINT message_tags_tag_id_fkey FOREIGN KEY (tag_id) REFERENCES public.tags(id)
);
-- Default settings
INSERT INTO settings ("key", value)
VALUES
('app.lang', '"en"'::jsonb),
('app.root_url', '"http://localhost:9009"'::jsonb),
('app.site_name', '"Helpdesk"'::jsonb),
('app.favicon_url', '""'::jsonb),
('app.max_file_upload_size', '20'::jsonb),
('app.allowed_file_upload_extensions', '["*"]'::jsonb),
('notification.email.username', '""'::jsonb),
('notification.email.host', '""'::jsonb),
('notification.email.port', '587'::jsonb),
('notification.email.password', '""'::jsonb),
('notification.email.max_conns', '5'::jsonb),
('notification.email.idle_timeout', '"30s"'::jsonb),
('notification.email.wait_timeout', '"30s"'::jsonb),
('notification.email.auth_protocol', '""'::jsonb),
('notification.email.email_address', '""'::jsonb),
('notification.email.max_msg_retries', '3'::jsonb),
('notification.email.enabled', 'false'::jsonb);