Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -168,21 +168,23 @@ type IncompleteDetails struct {
}

type Conversation struct {
ID uint `json:"-"`
PublicID string `json:"id"` // OpenAI-compatible string ID like "conv_abc123"
Title *string `json:"title,omitempty"`
UserID uint `json:"-"`
Status ConversationStatus `json:"status"`
Items []Item `json:"items,omitempty"`
Metadata map[string]string `json:"metadata,omitempty"`
IsPrivate bool `json:"is_private"`
CreatedAt time.Time `json:"created_at"` // Unix timestamp for OpenAI compatibility
UpdatedAt time.Time `json:"updated_at"` // Unix timestamp for OpenAI compatibility
ID uint `json:"-"`
PublicID string `json:"id"` // OpenAI-compatible string ID like "conv_abc123"
Title *string `json:"title,omitempty"`
UserID uint `json:"-"`
WorkspacePublicID *string `json:"workspace_id,omitempty"`
Status ConversationStatus `json:"status"`
Items []Item `json:"items,omitempty"`
Metadata map[string]string `json:"metadata,omitempty"`
IsPrivate bool `json:"is_private"`
CreatedAt time.Time `json:"created_at"` // Unix timestamp for OpenAI compatibility
UpdatedAt time.Time `json:"updated_at"` // Unix timestamp for OpenAI compatibility
}

type ConversationFilter struct {
PublicID *string
UserID *uint
PublicID *string
UserID *uint
WorkspacePublicID *string
}

type ItemFilter struct {
Expand All @@ -200,6 +202,7 @@ type ConversationRepository interface {
FindByPublicID(ctx context.Context, publicID string) (*Conversation, error)
Update(ctx context.Context, conversation *Conversation) error
Delete(ctx context.Context, id uint) error
DeleteByWorkspacePublicID(ctx context.Context, workspacePublicID string) error
AddItem(ctx context.Context, conversationID uint, item *Item) error
SearchItems(ctx context.Context, conversationID uint, query string) ([]*Item, error)
BulkAddItems(ctx context.Context, conversationID uint, items []*Item) error
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ func (s *ConversationService) CountConversationsByFilter(ctx context.Context, fi
return count, nil
}

func (s *ConversationService) CreateConversation(ctx context.Context, userID uint, title *string, isPrivate bool, metadata map[string]string) (*Conversation, *common.Error) {
func (s *ConversationService) CreateConversation(ctx context.Context, userID uint, title *string, isPrivate bool, metadata map[string]string, workspacePublicID *string) (*Conversation, *common.Error) {
if err := s.validator.ValidateConversationInput(title, metadata); err != nil {
return nil, common.NewError(err, "c3d4e5f6-g7h8-9012-cdef-345678901234")
}
Expand All @@ -71,12 +71,13 @@ func (s *ConversationService) CreateConversation(ctx context.Context, userID uin
}

conversation := &Conversation{
PublicID: publicID,
Title: title,
UserID: userID,
Status: ConversationStatusActive,
IsPrivate: isPrivate,
Metadata: metadata,
PublicID: publicID,
Title: title,
UserID: userID,
WorkspacePublicID: workspacePublicID,
Status: ConversationStatusActive,
IsPrivate: isPrivate,
Metadata: metadata,
}

if err := s.conversationRepo.Create(ctx, conversation); err != nil {
Expand Down Expand Up @@ -136,6 +137,14 @@ func (s *ConversationService) UpdateConversation(ctx context.Context, entity *Co
return entity, nil
}

func (s *ConversationService) UpdateConversationWorkspace(ctx context.Context, conv *Conversation, workspacePublicID *string) (*Conversation, *common.Error) {
conv.WorkspacePublicID = workspacePublicID
if err := s.conversationRepo.Update(ctx, conv); err != nil {
return nil, common.NewError(err, "a3b4c5d6-e7f8-9012-abcd-ef3456789012")
}
return conv, nil
}

func (s *ConversationService) DeleteConversation(ctx context.Context, conv *Conversation) (bool, *common.Error) {
if err := s.conversationRepo.Delete(ctx, conv.ID); err != nil {
return false, common.NewError(err, "m3n4o5p6-q7r8-9012-mnop-345678901234")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -554,7 +554,7 @@ func (s *ResponseService) HandleConversation(ctx context.Context, userID uint, r
}

// Create new conversation
conv, err := s.conversationService.CreateConversation(ctx, userID, nil, true, nil)
conv, err := s.conversationService.CreateConversation(ctx, userID, nil, true, nil, nil)
if err != nil {
return nil, err
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"menlo.ai/jan-api-gateway/app/domain/project"
"menlo.ai/jan-api-gateway/app/domain/response"
"menlo.ai/jan-api-gateway/app/domain/user"
"menlo.ai/jan-api-gateway/app/domain/workspace"
)

var ServiceProvider = wire.NewSet(
Expand All @@ -22,6 +23,7 @@ var ServiceProvider = wire.NewSet(
apikey.NewService,
user.NewService,
conversation.NewService,
workspace.NewWorkspaceService,
response.NewResponseService,
response.NewResponseModelService,
response.NewStreamModelService,
Expand Down
35 changes: 35 additions & 0 deletions apps/jan-api-gateway/application/app/domain/workspace/workspace.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package workspace

import (
"context"
"time"

"menlo.ai/jan-api-gateway/app/domain/query"
)

type Workspace struct {
ID uint
PublicID string
UserID uint
Name string
Instruction *string
CreatedAt time.Time
UpdatedAt time.Time
}

type WorkspaceFilter struct {
UserID *uint
PublicID *string
PublicIDs *[]string
IDs *[]uint
}

type WorkspaceRepository interface {
Create(ctx context.Context, workspace *Workspace) error
Update(ctx context.Context, workspace *Workspace) error
Delete(ctx context.Context, id uint) error
FindByID(ctx context.Context, id uint) (*Workspace, error)
FindByPublicID(ctx context.Context, publicID string) (*Workspace, error)
FindByFilter(ctx context.Context, filter WorkspaceFilter, pagination *query.Pagination) ([]*Workspace, error)
Count(ctx context.Context, filter WorkspaceFilter) (int64, error)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
package workspace

import (
"context"
"net/http"
"strings"

"github.com/gin-gonic/gin"

"menlo.ai/jan-api-gateway/app/domain/auth"
"menlo.ai/jan-api-gateway/app/domain/common"
"menlo.ai/jan-api-gateway/app/domain/conversation"
"menlo.ai/jan-api-gateway/app/domain/query"
"menlo.ai/jan-api-gateway/app/interfaces/http/responses"
"menlo.ai/jan-api-gateway/app/utils/idgen"
)

type WorkspaceContextKey string

const (
WorkspaceContextKeyPublicID WorkspaceContextKey = "workspace_public_id"
WorkspaceContextEntity WorkspaceContextKey = "WorkspaceContextEntity"
)

type WorkspaceService struct {
repo WorkspaceRepository
conversationRepo conversation.ConversationRepository
}

func NewWorkspaceService(repo WorkspaceRepository, conversationRepo conversation.ConversationRepository) *WorkspaceService {
return &WorkspaceService{
repo: repo,
conversationRepo: conversationRepo,
}
}

func (s *WorkspaceService) FindWorkspacesByFilter(ctx context.Context, filter WorkspaceFilter, pagination *query.Pagination) ([]*Workspace, *common.Error) {
workspaces, err := s.repo.FindByFilter(ctx, filter, pagination)
if err != nil {
return nil, common.NewError(err, "13df5d74-32c4-4b87-9066-6f9c546f4ad2")
}
return workspaces, nil
}

func (s *WorkspaceService) CreateWorkspace(ctx context.Context, userID uint, name string, instruction *string) (*Workspace, *common.Error) {
trimmedName := strings.TrimSpace(name)
if trimmedName == "" {
return nil, common.NewErrorWithMessage("workspace name is required", "3a5dcb2f-9f1c-4f4b-8893-4a62f72f7a00")
}50
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

50?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's removed in next commit

if len([]rune(trimmedName)) > 50 {
return nil, common.NewErrorWithMessage("workspace name is too long", "94a6a12b-d4f0-4594-8125-95de7f9ce3d6")
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

move these good validation to entity so we can use reproduce the same logic

func (w *Workspace) Nomorlize() error {
trimmedName := strings.TrimSpace(w.name)
	if trimmedName == "" {
		return nil, common.NewErrorWithMessage("workspace name is required", "3a5dcb2f-9f1c-4f4b-8893-4a62f72f7a00")
	}
	if len([]rune(trimmedName)) > 50 {
		return nil, common.NewErrorWithMessage("workspace name is too long", "94a6a12b-d4f0-4594-8125-95de7f9ce3d6")
	}
return error or nil
}


sanitizedInstruction := sanitizeInstruction(instruction)

publicID, err := idgen.GenerateSecureID("ws", 24)
if err != nil {
return nil, common.NewError(err, "6d4af582-0c23-4f91-b45e-253956218b64")
}

workspace := &Workspace{
PublicID: publicID,
UserID: userID,
Name: trimmedName,
Instruction: sanitizedInstruction,
}

if err := s.repo.Create(ctx, workspace); err != nil {
return nil, common.NewError(err, "7ef72c57-90f8-4d59-8d08-2b2edf61d8da")
}

return workspace, nil
}

func (s *WorkspaceService) GetWorkspaceByPublicIDAndUserID(ctx context.Context, publicID string, userID uint) (*Workspace, *common.Error) {
if publicID == "" {
return nil, common.NewErrorWithMessage("workspace id is required", "70d9041a-a3a5-4654-af30-2b530eb3e734")
}

workspaces, err := s.repo.FindByFilter(ctx, WorkspaceFilter{
PublicID: &publicID,
UserID: &userID,
}, nil)
if err != nil {
return nil, common.NewError(err, "ad9be074-4c1e-4d43-828d-fc9e7efc0c52")
}
if len(workspaces) == 0 {
return nil, common.NewErrorWithMessage("workspace not found", "c8bc424c-5b20-4cf9-8ca1-7d9ad1b098c8")
}
if len(workspaces) > 1 {
return nil, common.NewErrorWithMessage("multiple workspaces found", "0d0ff761-aa21-4d0b-91c3-acc0f3fa652f")
}
return workspaces[0], nil
}

func (s *WorkspaceService) UpdateWorkspaceName(ctx context.Context, workspace *Workspace, name string) (*Workspace, *common.Error) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

how about change to UpdateWorkspace(ctx context.Context, workspace *Workspace)

e := w.normalize()
if e == nil{
  // give up
}
UpdateWorkspace(ctx, w)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I split the Name and Instruction that Instruction can be huge text.
Name is minimal changes, we just split them to make everything lightweight

trimmedName := strings.TrimSpace(name)
if trimmedName == "" {
return nil, common.NewErrorWithMessage("workspace name is required", "71cf6385-8ca9-4f25-9ad5-2f3ec0e0f765")
}
if len([]rune(trimmedName)) > 50 {
return nil, common.NewErrorWithMessage("workspace name is too long", "d36f9e9f-db49-4d06-81db-75adf127cd7c")
}

workspace.Name = trimmedName
if err := s.repo.Update(ctx, workspace); err != nil {
return nil, common.NewError(err, "4e4c3a63-9e3c-420a-84f7-4415a7c21e61")
}
return workspace, nil
}

func (s *WorkspaceService) UpdateWorkspaceInstruction(ctx context.Context, workspace *Workspace, instruction *string) (*Workspace, *common.Error) {
workspace.Instruction = sanitizeInstruction(instruction)
if err := s.repo.Update(ctx, workspace); err != nil {
return nil, common.NewError(err, "1c59f37a-56fa-4f64-9d8c-8a6c99b2e3ee")
}
return workspace, nil
}
Comment on lines +97 to +103
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It feels like adding a new field to the workspace requires adding a new function, which is not a good pattern.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Live above comment, don't introduce PUT and using Patch on Name and Instruction as Name is small and Instruction can be huge text.


func (s *WorkspaceService) DeleteWorkspaceWithConversations(ctx context.Context, workspace *Workspace) *common.Error {
if workspace == nil {
return common.NewErrorWithMessage("workspace is required", "5d35c9b3-61f6-4c40-b6f8-31e0de1d7688")
}
if workspace.ID == 0 {
return common.NewErrorWithMessage("workspace id is required", "7e2f82a6-1c4f-4f67-9ef6-8790896eb99c")
}
if s.conversationRepo != nil {
if err := s.conversationRepo.DeleteByWorkspacePublicID(ctx, workspace.PublicID); err != nil {
return common.NewError(err, "2adf58f7-df2c-4f7f-bc11-2e9a2928c1f9")
}
}
Comment on lines +112 to +116
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In most cases, we should let cascade deletion handle the job.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know why cascade don't work. so I delete it by id to make something clean

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's try repo.db.GetTx(ctx).Unscoped().Delete()...

if err := s.repo.Delete(ctx, workspace.ID); err != nil {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will the cascade delete work if we just delete the workspace?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No. I tested before and it doesn't work. so I have to delete by workspace id first

return common.NewError(err, "4cfb58ef-8016-4f24-8fcb-48d414d351d2")
}
return nil
}

func (s *WorkspaceService) GetWorkspaceMiddleware() gin.HandlerFunc {
return func(reqCtx *gin.Context) {
ctx := reqCtx.Request.Context()
workspaceID := reqCtx.Param(string(WorkspaceContextKeyPublicID))
if workspaceID == "" {
reqCtx.AbortWithStatusJSON(http.StatusBadRequest, responses.ErrorResponse{
Code: "8dbbdf92-0ff6-4b70-99ee-0a6fe48eab8a",
Error: "missing workspace id",
})
return
}

user, ok := auth.GetUserFromContext(reqCtx)
if !ok {
reqCtx.AbortWithStatusJSON(http.StatusUnauthorized, responses.ErrorResponse{
Code: "19d3e0aa-38db-42f4-9ed0-d4f02b8c7c2d",
Error: "user not found",
})
return
}

workspace, err := s.GetWorkspaceByPublicIDAndUserID(ctx, workspaceID, user.ID)
if err != nil {
status := http.StatusInternalServerError
if err.GetCode() == "c8bc424c-5b20-4cf9-8ca1-7d9ad1b098c8" {
status = http.StatusNotFound
}
reqCtx.AbortWithStatusJSON(status, responses.ErrorResponse{
Code: err.GetCode(),
Error: err.Error(),
})
return
}

SetWorkspaceOnContext(reqCtx, workspace)
reqCtx.Next()
}
}

func sanitizeInstruction(instruction *string) *string {
if instruction == nil {
return nil
}
trimmed := strings.TrimSpace(*instruction)
if trimmed == "" {
return nil
}
return &trimmed
}

func SetWorkspaceOnContext(reqCtx *gin.Context, workspace *Workspace) {
reqCtx.Set(string(WorkspaceContextEntity), workspace)
}

func GetWorkspaceFromContext(reqCtx *gin.Context) (*Workspace, bool) {
value, ok := reqCtx.Get(string(WorkspaceContextEntity))
if !ok {
return nil, false
}
workspace, ok := value.(*Workspace)
if !ok {
return nil, false
}
return workspace, true
}
Loading
Loading