Compare commits
17 Commits
706d562e3e
...
main
Author | SHA1 | Date | |
---|---|---|---|
70161da3ed | |||
3a182fc49b | |||
ec7bd469f9 | |||
6523b10699 | |||
61d2b81e8c | |||
fe0968716d | |||
769f3981cd | |||
a78f766122 | |||
10cea769bf | |||
f5e65524aa | |||
390a216260 | |||
3e57d10360 | |||
28a4b37dde | |||
4de4431390 | |||
5ff7788a7b | |||
13170a33e8 | |||
5024933852 |
22
backend/.gen/haystack/haystack/model/processing_lists.go
Normal file
22
backend/.gen/haystack/haystack/model/processing_lists.go
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
//
|
||||||
|
// Code generated by go-jet DO NOT EDIT.
|
||||||
|
//
|
||||||
|
// WARNING: Changes to this file may cause incorrect behavior
|
||||||
|
// and will be lost if the code is regenerated
|
||||||
|
//
|
||||||
|
|
||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ProcessingLists struct {
|
||||||
|
ID uuid.UUID `sql:"primary_key"`
|
||||||
|
UserID uuid.UUID
|
||||||
|
Title string
|
||||||
|
Fields string
|
||||||
|
Status Progress
|
||||||
|
CreatedAt *time.Time
|
||||||
|
}
|
@ -20,10 +20,11 @@ type imageTable struct {
|
|||||||
ID postgres.ColumnString
|
ID postgres.ColumnString
|
||||||
ImageName postgres.ColumnString
|
ImageName postgres.ColumnString
|
||||||
Description postgres.ColumnString
|
Description postgres.ColumnString
|
||||||
Image postgres.ColumnString
|
Image postgres.ColumnBytea
|
||||||
|
|
||||||
AllColumns postgres.ColumnList
|
AllColumns postgres.ColumnList
|
||||||
MutableColumns postgres.ColumnList
|
MutableColumns postgres.ColumnList
|
||||||
|
DefaultColumns postgres.ColumnList
|
||||||
}
|
}
|
||||||
|
|
||||||
type ImageTable struct {
|
type ImageTable struct {
|
||||||
@ -64,9 +65,10 @@ func newImageTableImpl(schemaName, tableName, alias string) imageTable {
|
|||||||
IDColumn = postgres.StringColumn("id")
|
IDColumn = postgres.StringColumn("id")
|
||||||
ImageNameColumn = postgres.StringColumn("image_name")
|
ImageNameColumn = postgres.StringColumn("image_name")
|
||||||
DescriptionColumn = postgres.StringColumn("description")
|
DescriptionColumn = postgres.StringColumn("description")
|
||||||
ImageColumn = postgres.StringColumn("image")
|
ImageColumn = postgres.ByteaColumn("image")
|
||||||
allColumns = postgres.ColumnList{IDColumn, ImageNameColumn, DescriptionColumn, ImageColumn}
|
allColumns = postgres.ColumnList{IDColumn, ImageNameColumn, DescriptionColumn, ImageColumn}
|
||||||
mutableColumns = postgres.ColumnList{ImageNameColumn, DescriptionColumn, ImageColumn}
|
mutableColumns = postgres.ColumnList{ImageNameColumn, DescriptionColumn, ImageColumn}
|
||||||
|
defaultColumns = postgres.ColumnList{IDColumn}
|
||||||
)
|
)
|
||||||
|
|
||||||
return imageTable{
|
return imageTable{
|
||||||
@ -80,5 +82,6 @@ func newImageTableImpl(schemaName, tableName, alias string) imageTable {
|
|||||||
|
|
||||||
AllColumns: allColumns,
|
AllColumns: allColumns,
|
||||||
MutableColumns: mutableColumns,
|
MutableColumns: mutableColumns,
|
||||||
|
DefaultColumns: defaultColumns,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -23,6 +23,7 @@ type imageListsTable struct {
|
|||||||
|
|
||||||
AllColumns postgres.ColumnList
|
AllColumns postgres.ColumnList
|
||||||
MutableColumns postgres.ColumnList
|
MutableColumns postgres.ColumnList
|
||||||
|
DefaultColumns postgres.ColumnList
|
||||||
}
|
}
|
||||||
|
|
||||||
type ImageListsTable struct {
|
type ImageListsTable struct {
|
||||||
@ -65,6 +66,7 @@ func newImageListsTableImpl(schemaName, tableName, alias string) imageListsTable
|
|||||||
ListIDColumn = postgres.StringColumn("list_id")
|
ListIDColumn = postgres.StringColumn("list_id")
|
||||||
allColumns = postgres.ColumnList{IDColumn, ImageIDColumn, ListIDColumn}
|
allColumns = postgres.ColumnList{IDColumn, ImageIDColumn, ListIDColumn}
|
||||||
mutableColumns = postgres.ColumnList{ImageIDColumn, ListIDColumn}
|
mutableColumns = postgres.ColumnList{ImageIDColumn, ListIDColumn}
|
||||||
|
defaultColumns = postgres.ColumnList{IDColumn}
|
||||||
)
|
)
|
||||||
|
|
||||||
return imageListsTable{
|
return imageListsTable{
|
||||||
@ -77,5 +79,6 @@ func newImageListsTableImpl(schemaName, tableName, alias string) imageListsTable
|
|||||||
|
|
||||||
AllColumns: allColumns,
|
AllColumns: allColumns,
|
||||||
MutableColumns: mutableColumns,
|
MutableColumns: mutableColumns,
|
||||||
|
DefaultColumns: defaultColumns,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -24,6 +24,7 @@ type imageSchemaItemsTable struct {
|
|||||||
|
|
||||||
AllColumns postgres.ColumnList
|
AllColumns postgres.ColumnList
|
||||||
MutableColumns postgres.ColumnList
|
MutableColumns postgres.ColumnList
|
||||||
|
DefaultColumns postgres.ColumnList
|
||||||
}
|
}
|
||||||
|
|
||||||
type ImageSchemaItemsTable struct {
|
type ImageSchemaItemsTable struct {
|
||||||
@ -67,6 +68,7 @@ func newImageSchemaItemsTableImpl(schemaName, tableName, alias string) imageSche
|
|||||||
ImageIDColumn = postgres.StringColumn("image_id")
|
ImageIDColumn = postgres.StringColumn("image_id")
|
||||||
allColumns = postgres.ColumnList{IDColumn, ValueColumn, SchemaItemIDColumn, ImageIDColumn}
|
allColumns = postgres.ColumnList{IDColumn, ValueColumn, SchemaItemIDColumn, ImageIDColumn}
|
||||||
mutableColumns = postgres.ColumnList{ValueColumn, SchemaItemIDColumn, ImageIDColumn}
|
mutableColumns = postgres.ColumnList{ValueColumn, SchemaItemIDColumn, ImageIDColumn}
|
||||||
|
defaultColumns = postgres.ColumnList{IDColumn}
|
||||||
)
|
)
|
||||||
|
|
||||||
return imageSchemaItemsTable{
|
return imageSchemaItemsTable{
|
||||||
@ -80,5 +82,6 @@ func newImageSchemaItemsTableImpl(schemaName, tableName, alias string) imageSche
|
|||||||
|
|
||||||
AllColumns: allColumns,
|
AllColumns: allColumns,
|
||||||
MutableColumns: mutableColumns,
|
MutableColumns: mutableColumns,
|
||||||
|
DefaultColumns: defaultColumns,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -25,6 +25,7 @@ type listsTable struct {
|
|||||||
|
|
||||||
AllColumns postgres.ColumnList
|
AllColumns postgres.ColumnList
|
||||||
MutableColumns postgres.ColumnList
|
MutableColumns postgres.ColumnList
|
||||||
|
DefaultColumns postgres.ColumnList
|
||||||
}
|
}
|
||||||
|
|
||||||
type ListsTable struct {
|
type ListsTable struct {
|
||||||
@ -69,6 +70,7 @@ func newListsTableImpl(schemaName, tableName, alias string) listsTable {
|
|||||||
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
||||||
allColumns = postgres.ColumnList{IDColumn, UserIDColumn, NameColumn, DescriptionColumn, CreatedAtColumn}
|
allColumns = postgres.ColumnList{IDColumn, UserIDColumn, NameColumn, DescriptionColumn, CreatedAtColumn}
|
||||||
mutableColumns = postgres.ColumnList{UserIDColumn, NameColumn, DescriptionColumn, CreatedAtColumn}
|
mutableColumns = postgres.ColumnList{UserIDColumn, NameColumn, DescriptionColumn, CreatedAtColumn}
|
||||||
|
defaultColumns = postgres.ColumnList{IDColumn, CreatedAtColumn}
|
||||||
)
|
)
|
||||||
|
|
||||||
return listsTable{
|
return listsTable{
|
||||||
@ -83,5 +85,6 @@ func newListsTableImpl(schemaName, tableName, alias string) listsTable {
|
|||||||
|
|
||||||
AllColumns: allColumns,
|
AllColumns: allColumns,
|
||||||
MutableColumns: mutableColumns,
|
MutableColumns: mutableColumns,
|
||||||
|
DefaultColumns: defaultColumns,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -23,6 +23,7 @@ type logsTable struct {
|
|||||||
|
|
||||||
AllColumns postgres.ColumnList
|
AllColumns postgres.ColumnList
|
||||||
MutableColumns postgres.ColumnList
|
MutableColumns postgres.ColumnList
|
||||||
|
DefaultColumns postgres.ColumnList
|
||||||
}
|
}
|
||||||
|
|
||||||
type LogsTable struct {
|
type LogsTable struct {
|
||||||
@ -65,6 +66,7 @@ func newLogsTableImpl(schemaName, tableName, alias string) logsTable {
|
|||||||
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
||||||
allColumns = postgres.ColumnList{LogColumn, ImageIDColumn, CreatedAtColumn}
|
allColumns = postgres.ColumnList{LogColumn, ImageIDColumn, CreatedAtColumn}
|
||||||
mutableColumns = postgres.ColumnList{LogColumn, ImageIDColumn, CreatedAtColumn}
|
mutableColumns = postgres.ColumnList{LogColumn, ImageIDColumn, CreatedAtColumn}
|
||||||
|
defaultColumns = postgres.ColumnList{CreatedAtColumn}
|
||||||
)
|
)
|
||||||
|
|
||||||
return logsTable{
|
return logsTable{
|
||||||
@ -77,5 +79,6 @@ func newLogsTableImpl(schemaName, tableName, alias string) logsTable {
|
|||||||
|
|
||||||
AllColumns: allColumns,
|
AllColumns: allColumns,
|
||||||
MutableColumns: mutableColumns,
|
MutableColumns: mutableColumns,
|
||||||
|
DefaultColumns: defaultColumns,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
93
backend/.gen/haystack/haystack/table/processing_lists.go
Normal file
93
backend/.gen/haystack/haystack/table/processing_lists.go
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
//
|
||||||
|
// Code generated by go-jet DO NOT EDIT.
|
||||||
|
//
|
||||||
|
// WARNING: Changes to this file may cause incorrect behavior
|
||||||
|
// and will be lost if the code is regenerated
|
||||||
|
//
|
||||||
|
|
||||||
|
package table
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/go-jet/jet/v2/postgres"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ProcessingLists = newProcessingListsTable("haystack", "processing_lists", "")
|
||||||
|
|
||||||
|
type processingListsTable struct {
|
||||||
|
postgres.Table
|
||||||
|
|
||||||
|
// Columns
|
||||||
|
ID postgres.ColumnString
|
||||||
|
UserID postgres.ColumnString
|
||||||
|
Title postgres.ColumnString
|
||||||
|
Fields postgres.ColumnString
|
||||||
|
Status postgres.ColumnString
|
||||||
|
CreatedAt postgres.ColumnTimestampz
|
||||||
|
|
||||||
|
AllColumns postgres.ColumnList
|
||||||
|
MutableColumns postgres.ColumnList
|
||||||
|
DefaultColumns postgres.ColumnList
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProcessingListsTable struct {
|
||||||
|
processingListsTable
|
||||||
|
|
||||||
|
EXCLUDED processingListsTable
|
||||||
|
}
|
||||||
|
|
||||||
|
// AS creates new ProcessingListsTable with assigned alias
|
||||||
|
func (a ProcessingListsTable) AS(alias string) *ProcessingListsTable {
|
||||||
|
return newProcessingListsTable(a.SchemaName(), a.TableName(), alias)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schema creates new ProcessingListsTable with assigned schema name
|
||||||
|
func (a ProcessingListsTable) FromSchema(schemaName string) *ProcessingListsTable {
|
||||||
|
return newProcessingListsTable(schemaName, a.TableName(), a.Alias())
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithPrefix creates new ProcessingListsTable with assigned table prefix
|
||||||
|
func (a ProcessingListsTable) WithPrefix(prefix string) *ProcessingListsTable {
|
||||||
|
return newProcessingListsTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithSuffix creates new ProcessingListsTable with assigned table suffix
|
||||||
|
func (a ProcessingListsTable) WithSuffix(suffix string) *ProcessingListsTable {
|
||||||
|
return newProcessingListsTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
|
||||||
|
}
|
||||||
|
|
||||||
|
func newProcessingListsTable(schemaName, tableName, alias string) *ProcessingListsTable {
|
||||||
|
return &ProcessingListsTable{
|
||||||
|
processingListsTable: newProcessingListsTableImpl(schemaName, tableName, alias),
|
||||||
|
EXCLUDED: newProcessingListsTableImpl("", "excluded", ""),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newProcessingListsTableImpl(schemaName, tableName, alias string) processingListsTable {
|
||||||
|
var (
|
||||||
|
IDColumn = postgres.StringColumn("id")
|
||||||
|
UserIDColumn = postgres.StringColumn("user_id")
|
||||||
|
TitleColumn = postgres.StringColumn("title")
|
||||||
|
FieldsColumn = postgres.StringColumn("fields")
|
||||||
|
StatusColumn = postgres.StringColumn("status")
|
||||||
|
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
||||||
|
allColumns = postgres.ColumnList{IDColumn, UserIDColumn, TitleColumn, FieldsColumn, StatusColumn, CreatedAtColumn}
|
||||||
|
mutableColumns = postgres.ColumnList{UserIDColumn, TitleColumn, FieldsColumn, StatusColumn, CreatedAtColumn}
|
||||||
|
defaultColumns = postgres.ColumnList{IDColumn, StatusColumn, CreatedAtColumn}
|
||||||
|
)
|
||||||
|
|
||||||
|
return processingListsTable{
|
||||||
|
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
||||||
|
|
||||||
|
//Columns
|
||||||
|
ID: IDColumn,
|
||||||
|
UserID: UserIDColumn,
|
||||||
|
Title: TitleColumn,
|
||||||
|
Fields: FieldsColumn,
|
||||||
|
Status: StatusColumn,
|
||||||
|
CreatedAt: CreatedAtColumn,
|
||||||
|
|
||||||
|
AllColumns: allColumns,
|
||||||
|
MutableColumns: mutableColumns,
|
||||||
|
DefaultColumns: defaultColumns,
|
||||||
|
}
|
||||||
|
}
|
@ -25,6 +25,7 @@ type schemaItemsTable struct {
|
|||||||
|
|
||||||
AllColumns postgres.ColumnList
|
AllColumns postgres.ColumnList
|
||||||
MutableColumns postgres.ColumnList
|
MutableColumns postgres.ColumnList
|
||||||
|
DefaultColumns postgres.ColumnList
|
||||||
}
|
}
|
||||||
|
|
||||||
type SchemaItemsTable struct {
|
type SchemaItemsTable struct {
|
||||||
@ -69,6 +70,7 @@ func newSchemaItemsTableImpl(schemaName, tableName, alias string) schemaItemsTab
|
|||||||
SchemaIDColumn = postgres.StringColumn("schema_id")
|
SchemaIDColumn = postgres.StringColumn("schema_id")
|
||||||
allColumns = postgres.ColumnList{IDColumn, ItemColumn, ValueColumn, DescriptionColumn, SchemaIDColumn}
|
allColumns = postgres.ColumnList{IDColumn, ItemColumn, ValueColumn, DescriptionColumn, SchemaIDColumn}
|
||||||
mutableColumns = postgres.ColumnList{ItemColumn, ValueColumn, DescriptionColumn, SchemaIDColumn}
|
mutableColumns = postgres.ColumnList{ItemColumn, ValueColumn, DescriptionColumn, SchemaIDColumn}
|
||||||
|
defaultColumns = postgres.ColumnList{IDColumn}
|
||||||
)
|
)
|
||||||
|
|
||||||
return schemaItemsTable{
|
return schemaItemsTable{
|
||||||
@ -83,5 +85,6 @@ func newSchemaItemsTableImpl(schemaName, tableName, alias string) schemaItemsTab
|
|||||||
|
|
||||||
AllColumns: allColumns,
|
AllColumns: allColumns,
|
||||||
MutableColumns: mutableColumns,
|
MutableColumns: mutableColumns,
|
||||||
|
DefaultColumns: defaultColumns,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -22,6 +22,7 @@ type schemasTable struct {
|
|||||||
|
|
||||||
AllColumns postgres.ColumnList
|
AllColumns postgres.ColumnList
|
||||||
MutableColumns postgres.ColumnList
|
MutableColumns postgres.ColumnList
|
||||||
|
DefaultColumns postgres.ColumnList
|
||||||
}
|
}
|
||||||
|
|
||||||
type SchemasTable struct {
|
type SchemasTable struct {
|
||||||
@ -63,6 +64,7 @@ func newSchemasTableImpl(schemaName, tableName, alias string) schemasTable {
|
|||||||
ListIDColumn = postgres.StringColumn("list_id")
|
ListIDColumn = postgres.StringColumn("list_id")
|
||||||
allColumns = postgres.ColumnList{IDColumn, ListIDColumn}
|
allColumns = postgres.ColumnList{IDColumn, ListIDColumn}
|
||||||
mutableColumns = postgres.ColumnList{ListIDColumn}
|
mutableColumns = postgres.ColumnList{ListIDColumn}
|
||||||
|
defaultColumns = postgres.ColumnList{IDColumn}
|
||||||
)
|
)
|
||||||
|
|
||||||
return schemasTable{
|
return schemasTable{
|
||||||
@ -74,5 +76,6 @@ func newSchemasTableImpl(schemaName, tableName, alias string) schemasTable {
|
|||||||
|
|
||||||
AllColumns: allColumns,
|
AllColumns: allColumns,
|
||||||
MutableColumns: mutableColumns,
|
MutableColumns: mutableColumns,
|
||||||
|
DefaultColumns: defaultColumns,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -15,6 +15,7 @@ func UseSchema(schema string) {
|
|||||||
ImageSchemaItems = ImageSchemaItems.FromSchema(schema)
|
ImageSchemaItems = ImageSchemaItems.FromSchema(schema)
|
||||||
Lists = Lists.FromSchema(schema)
|
Lists = Lists.FromSchema(schema)
|
||||||
Logs = Logs.FromSchema(schema)
|
Logs = Logs.FromSchema(schema)
|
||||||
|
ProcessingLists = ProcessingLists.FromSchema(schema)
|
||||||
SchemaItems = SchemaItems.FromSchema(schema)
|
SchemaItems = SchemaItems.FromSchema(schema)
|
||||||
Schemas = Schemas.FromSchema(schema)
|
Schemas = Schemas.FromSchema(schema)
|
||||||
UserImages = UserImages.FromSchema(schema)
|
UserImages = UserImages.FromSchema(schema)
|
||||||
|
@ -24,6 +24,7 @@ type userImagesTable struct {
|
|||||||
|
|
||||||
AllColumns postgres.ColumnList
|
AllColumns postgres.ColumnList
|
||||||
MutableColumns postgres.ColumnList
|
MutableColumns postgres.ColumnList
|
||||||
|
DefaultColumns postgres.ColumnList
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserImagesTable struct {
|
type UserImagesTable struct {
|
||||||
@ -67,6 +68,7 @@ func newUserImagesTableImpl(schemaName, tableName, alias string) userImagesTable
|
|||||||
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
||||||
allColumns = postgres.ColumnList{IDColumn, ImageIDColumn, UserIDColumn, CreatedAtColumn}
|
allColumns = postgres.ColumnList{IDColumn, ImageIDColumn, UserIDColumn, CreatedAtColumn}
|
||||||
mutableColumns = postgres.ColumnList{ImageIDColumn, UserIDColumn, CreatedAtColumn}
|
mutableColumns = postgres.ColumnList{ImageIDColumn, UserIDColumn, CreatedAtColumn}
|
||||||
|
defaultColumns = postgres.ColumnList{IDColumn, CreatedAtColumn}
|
||||||
)
|
)
|
||||||
|
|
||||||
return userImagesTable{
|
return userImagesTable{
|
||||||
@ -80,5 +82,6 @@ func newUserImagesTableImpl(schemaName, tableName, alias string) userImagesTable
|
|||||||
|
|
||||||
AllColumns: allColumns,
|
AllColumns: allColumns,
|
||||||
MutableColumns: mutableColumns,
|
MutableColumns: mutableColumns,
|
||||||
|
DefaultColumns: defaultColumns,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -24,6 +24,7 @@ type userImagesToProcessTable struct {
|
|||||||
|
|
||||||
AllColumns postgres.ColumnList
|
AllColumns postgres.ColumnList
|
||||||
MutableColumns postgres.ColumnList
|
MutableColumns postgres.ColumnList
|
||||||
|
DefaultColumns postgres.ColumnList
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserImagesToProcessTable struct {
|
type UserImagesToProcessTable struct {
|
||||||
@ -67,6 +68,7 @@ func newUserImagesToProcessTableImpl(schemaName, tableName, alias string) userIm
|
|||||||
UserIDColumn = postgres.StringColumn("user_id")
|
UserIDColumn = postgres.StringColumn("user_id")
|
||||||
allColumns = postgres.ColumnList{IDColumn, StatusColumn, ImageIDColumn, UserIDColumn}
|
allColumns = postgres.ColumnList{IDColumn, StatusColumn, ImageIDColumn, UserIDColumn}
|
||||||
mutableColumns = postgres.ColumnList{StatusColumn, ImageIDColumn, UserIDColumn}
|
mutableColumns = postgres.ColumnList{StatusColumn, ImageIDColumn, UserIDColumn}
|
||||||
|
defaultColumns = postgres.ColumnList{IDColumn, StatusColumn}
|
||||||
)
|
)
|
||||||
|
|
||||||
return userImagesToProcessTable{
|
return userImagesToProcessTable{
|
||||||
@ -80,5 +82,6 @@ func newUserImagesToProcessTableImpl(schemaName, tableName, alias string) userIm
|
|||||||
|
|
||||||
AllColumns: allColumns,
|
AllColumns: allColumns,
|
||||||
MutableColumns: mutableColumns,
|
MutableColumns: mutableColumns,
|
||||||
|
DefaultColumns: defaultColumns,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -22,6 +22,7 @@ type usersTable struct {
|
|||||||
|
|
||||||
AllColumns postgres.ColumnList
|
AllColumns postgres.ColumnList
|
||||||
MutableColumns postgres.ColumnList
|
MutableColumns postgres.ColumnList
|
||||||
|
DefaultColumns postgres.ColumnList
|
||||||
}
|
}
|
||||||
|
|
||||||
type UsersTable struct {
|
type UsersTable struct {
|
||||||
@ -63,6 +64,7 @@ func newUsersTableImpl(schemaName, tableName, alias string) usersTable {
|
|||||||
EmailColumn = postgres.StringColumn("email")
|
EmailColumn = postgres.StringColumn("email")
|
||||||
allColumns = postgres.ColumnList{IDColumn, EmailColumn}
|
allColumns = postgres.ColumnList{IDColumn, EmailColumn}
|
||||||
mutableColumns = postgres.ColumnList{EmailColumn}
|
mutableColumns = postgres.ColumnList{EmailColumn}
|
||||||
|
defaultColumns = postgres.ColumnList{IDColumn}
|
||||||
)
|
)
|
||||||
|
|
||||||
return usersTable{
|
return usersTable{
|
||||||
@ -74,5 +76,6 @@ func newUsersTableImpl(schemaName, tableName, alias string) usersTable {
|
|||||||
|
|
||||||
AllColumns: allColumns,
|
AllColumns: allColumns,
|
||||||
MutableColumns: mutableColumns,
|
MutableColumns: mutableColumns,
|
||||||
|
DefaultColumns: defaultColumns,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -187,6 +187,15 @@ func (chat *Chat) AddSystem(prompt string) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (chat *Chat) AddUser(msg string) {
|
||||||
|
chat.Messages = append(chat.Messages, ChatUserMessage{
|
||||||
|
Role: User,
|
||||||
|
MessageContent: SingleMessage{
|
||||||
|
Content: msg,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (chat *Chat) AddImage(imageName string, image []byte, query *string) error {
|
func (chat *Chat) AddImage(imageName string, image []byte, query *string) error {
|
||||||
extension := filepath.Ext(imageName)
|
extension := filepath.Ext(imageName)
|
||||||
if len(extension) == 0 {
|
if len(extension) == 0 {
|
||||||
|
@ -133,29 +133,29 @@ func (client AgentClient) getRequest(body []byte) (*http.Request, error) {
|
|||||||
func (client AgentClient) Request(req *AgentRequestBody) (AgentResponse, error) {
|
func (client AgentClient) Request(req *AgentRequestBody) (AgentResponse, error) {
|
||||||
jsonAiRequest, err := json.Marshal(req)
|
jsonAiRequest, err := json.Marshal(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return AgentResponse{}, fmt.Errorf("Could not format JSON", err)
|
return AgentResponse{}, fmt.Errorf("Could not format JSON: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
httpRequest, err := client.getRequest(jsonAiRequest)
|
httpRequest, err := client.getRequest(jsonAiRequest)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return AgentResponse{}, fmt.Errorf("Could not get request", err)
|
return AgentResponse{}, fmt.Errorf("Could not get request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := client.Do(httpRequest)
|
resp, err := client.Do(httpRequest)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return AgentResponse{}, fmt.Errorf("Could not send request", err)
|
return AgentResponse{}, fmt.Errorf("Could not send request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
response, err := io.ReadAll(resp.Body)
|
response, err := io.ReadAll(resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return AgentResponse{}, fmt.Errorf("Could not read body", err)
|
return AgentResponse{}, fmt.Errorf("Could not read body: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
agentResponse := AgentResponse{}
|
agentResponse := AgentResponse{}
|
||||||
err = json.Unmarshal(response, &agentResponse)
|
err = json.Unmarshal(response, &agentResponse)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return AgentResponse{}, fmt.Errorf("Could not unmarshal response, response: %s", string(response), err)
|
return AgentResponse{}, fmt.Errorf("Could not unmarshal response, response: %s: %w", string(response), err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(agentResponse.Choices) != 1 {
|
if len(agentResponse.Choices) != 1 {
|
||||||
@ -270,3 +270,38 @@ func (client *AgentClient) RunAgent(userId uuid.UUID, imageId uuid.UUID, imageNa
|
|||||||
|
|
||||||
return client.ToolLoop(toolHandlerInfo, &request)
|
return client.ToolLoop(toolHandlerInfo, &request)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (client *AgentClient) RunAgentAlone(userID uuid.UUID, userReq string) error {
|
||||||
|
var tools any
|
||||||
|
err := json.Unmarshal([]byte(client.Options.JsonTools), &tools)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
toolChoice := "auto"
|
||||||
|
seed := 42
|
||||||
|
|
||||||
|
request := AgentRequestBody{
|
||||||
|
Tools: &tools,
|
||||||
|
ToolChoice: &toolChoice,
|
||||||
|
Model: "google/gemini-2.5-flash",
|
||||||
|
RandomSeed: &seed,
|
||||||
|
Temperature: 0.3,
|
||||||
|
EndToolCall: client.Options.EndToolCall,
|
||||||
|
ResponseFormat: ResponseFormat{
|
||||||
|
Type: "text",
|
||||||
|
},
|
||||||
|
Chat: &Chat{
|
||||||
|
Messages: make([]ChatMessage, 0),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
request.Chat.AddSystem(client.Options.SystemPrompt)
|
||||||
|
request.Chat.AddUser(userReq)
|
||||||
|
|
||||||
|
toolHandlerInfo := ToolHandlerInfo{
|
||||||
|
UserId: userID,
|
||||||
|
}
|
||||||
|
|
||||||
|
return client.ToolLoop(toolHandlerInfo, &request)
|
||||||
|
}
|
||||||
|
140
backend/agents/create_list_agent.go
Normal file
140
backend/agents/create_list_agent.go
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
package agents
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"screenmark/screenmark/.gen/haystack/haystack/model"
|
||||||
|
"screenmark/screenmark/agents/client"
|
||||||
|
"screenmark/screenmark/models"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/log"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
const createListAgentPrompt = `
|
||||||
|
You are an agent who's job is to produce a reasonable output for an unstructured input.
|
||||||
|
|
||||||
|
Your job is to create lists for the user, the user will give you a title and some fields they want
|
||||||
|
as part of the list. Your job is to take these fields, adjust their names so they have good names,
|
||||||
|
and add a good description for each one.
|
||||||
|
|
||||||
|
You can add fields if you think they make a lot of sense.
|
||||||
|
You can remove fields if they are not correct, but be sure before you do this.
|
||||||
|
`
|
||||||
|
|
||||||
|
const listJsonSchema = `
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"title": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "the title of the list"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "the description of the list"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The name of the field."
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "A description of the field."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"name",
|
||||||
|
"description"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"description": "An array of field objects."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"fields"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
type createNewListArguments struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
|
||||||
|
Fields []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
} `json:"fields"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateListAgent struct {
|
||||||
|
client client.AgentClient
|
||||||
|
|
||||||
|
listModel models.ListModel
|
||||||
|
}
|
||||||
|
|
||||||
|
func (agent *CreateListAgent) CreateList(log *log.Logger, userID uuid.UUID, userReq string) error {
|
||||||
|
request := client.AgentRequestBody{
|
||||||
|
Model: "google/gemini-2.5-flash",
|
||||||
|
Temperature: 0.3,
|
||||||
|
ResponseFormat: client.ResponseFormat{
|
||||||
|
Type: "json_object",
|
||||||
|
JsonSchema: listJsonSchema,
|
||||||
|
},
|
||||||
|
Chat: &client.Chat{
|
||||||
|
Messages: make([]client.ChatMessage, 0),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
request.Chat.AddSystem(agent.client.Options.SystemPrompt)
|
||||||
|
request.Chat.AddUser(userReq)
|
||||||
|
|
||||||
|
resp, err := agent.client.Request(&request)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
structuredOutput := resp.Choices[0].Message.Content
|
||||||
|
|
||||||
|
var createListArgs createNewListArguments
|
||||||
|
err = json.Unmarshal([]byte(structuredOutput), &createListArgs)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
schemaItems := make([]model.SchemaItems, 0)
|
||||||
|
for _, field := range createListArgs.Fields {
|
||||||
|
schemaItems = append(schemaItems, model.SchemaItems{
|
||||||
|
Item: field.Name,
|
||||||
|
Description: field.Description,
|
||||||
|
|
||||||
|
Value: "string", // keep it simple for now.
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
agent.listModel.Save(ctx, userID, createListArgs.Title, createListArgs.Description, schemaItems)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCreateListAgent(log *log.Logger, listModel models.ListModel) CreateListAgent {
|
||||||
|
client := client.CreateAgentClient(client.CreateAgentClientOptions{
|
||||||
|
SystemPrompt: createListAgentPrompt,
|
||||||
|
Log: log,
|
||||||
|
})
|
||||||
|
|
||||||
|
agent := CreateListAgent{
|
||||||
|
client,
|
||||||
|
listModel,
|
||||||
|
}
|
||||||
|
|
||||||
|
return agent
|
||||||
|
}
|
@ -15,6 +15,9 @@ You are an AI agent who's job is to describe the image you see.
|
|||||||
|
|
||||||
You should also add any text you see in the image, if no text exists, just add a description.
|
You should also add any text you see in the image, if no text exists, just add a description.
|
||||||
Be consise and don't add too much extra information or formatting characters, simple text.
|
Be consise and don't add too much extra information or formatting characters, simple text.
|
||||||
|
|
||||||
|
You must write this text in Markdown. You can add extra information for the user.
|
||||||
|
You must organise this text nicely, not be all over the place.
|
||||||
`
|
`
|
||||||
|
|
||||||
type DescriptionAgent struct {
|
type DescriptionAgent struct {
|
||||||
|
@ -33,6 +33,8 @@ and extract some meaning about what the image is.
|
|||||||
You must call "listLists" to see which available lists are already available.
|
You must call "listLists" to see which available lists are already available.
|
||||||
Use "createList" only once, don't create multiple lists for one image.
|
Use "createList" only once, don't create multiple lists for one image.
|
||||||
|
|
||||||
|
You can add an image to multiple lists, this is also true if you already created a list. But only add to a list if it makes sense to do so.
|
||||||
|
|
||||||
**Tools:**
|
**Tools:**
|
||||||
* think: Internal reasoning/planning step.
|
* think: Internal reasoning/planning step.
|
||||||
* listLists: Get existing lists
|
* listLists: Get existing lists
|
||||||
@ -184,10 +186,6 @@ func NewListAgent(log *log.Logger, listModel models.ListModel) client.AgentClien
|
|||||||
return "Thought", nil
|
return "Thought", nil
|
||||||
})
|
})
|
||||||
|
|
||||||
agentClient.ToolHandler.AddTool("listLists", func(info client.ToolHandlerInfo, args string, call client.ToolCall) (any, error) {
|
|
||||||
return listModel.List(context.Background(), info.UserId)
|
|
||||||
})
|
|
||||||
|
|
||||||
agentClient.ToolHandler.AddTool("createList", func(info client.ToolHandlerInfo, _args string, call client.ToolCall) (any, error) {
|
agentClient.ToolHandler.AddTool("createList", func(info client.ToolHandlerInfo, _args string, call client.ToolCall) (any, error) {
|
||||||
args := createListArguments{}
|
args := createListArguments{}
|
||||||
err := json.Unmarshal([]byte(_args), &args)
|
err := json.Unmarshal([]byte(_args), &args)
|
||||||
@ -208,6 +206,10 @@ func NewListAgent(log *log.Logger, listModel models.ListModel) client.AgentClien
|
|||||||
return savedList, nil
|
return savedList, nil
|
||||||
})
|
})
|
||||||
|
|
||||||
|
agentClient.ToolHandler.AddTool("listLists", func(info client.ToolHandlerInfo, args string, call client.ToolCall) (any, error) {
|
||||||
|
return listModel.List(context.Background(), info.UserId)
|
||||||
|
})
|
||||||
|
|
||||||
agentClient.ToolHandler.AddTool("addToList", func(info client.ToolHandlerInfo, _args string, call client.ToolCall) (any, error) {
|
agentClient.ToolHandler.AddTool("addToList", func(info client.ToolHandlerInfo, _args string, call client.ToolCall) (any, error) {
|
||||||
args := addToListArguments{}
|
args := addToListArguments{}
|
||||||
err := json.Unmarshal([]byte(_args), &args)
|
err := json.Unmarshal([]byte(_args), &args)
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
package main
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
@ -1,4 +1,4 @@
|
|||||||
package main
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
@ -1,9 +1,9 @@
|
|||||||
package main
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/log"
|
||||||
"github.com/wneessen/go-mail"
|
"github.com/wneessen/go-mail"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -11,7 +11,9 @@ type MailClient struct {
|
|||||||
client *mail.Client
|
client *mail.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
type TestMailClient struct{}
|
type TestMailClient struct {
|
||||||
|
logger *log.Logger
|
||||||
|
}
|
||||||
|
|
||||||
type Mailer interface {
|
type Mailer interface {
|
||||||
SendCode(to string, code string) error
|
SendCode(to string, code string) error
|
||||||
@ -43,15 +45,17 @@ func (m MailClient) SendCode(to string, code string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m TestMailClient) SendCode(to string, code string) error {
|
func (m TestMailClient) SendCode(to string, code string) error {
|
||||||
fmt.Printf("Email: %s | Code %s\n", to, code)
|
m.logger.Info("Auth Code", "email", to, "code", code)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func CreateMailClient() (Mailer, error) {
|
func CreateMailClient(log *log.Logger) (Mailer, error) {
|
||||||
mode := os.Getenv("MODE")
|
mode := os.Getenv("MODE")
|
||||||
if mode == "DEV" {
|
if mode == "DEV" {
|
||||||
return TestMailClient{}, nil
|
return TestMailClient{
|
||||||
|
log,
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
client, err := mail.NewClient(
|
client, err := mail.NewClient(
|
106
backend/auth/handler.go
Normal file
106
backend/auth/handler.go
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"screenmark/screenmark/.gen/haystack/haystack/model"
|
||||||
|
"screenmark/screenmark/middleware"
|
||||||
|
"screenmark/screenmark/models"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/log"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AuthHandler struct {
|
||||||
|
logger *log.Logger
|
||||||
|
|
||||||
|
user models.UserModel
|
||||||
|
|
||||||
|
auth Auth
|
||||||
|
}
|
||||||
|
|
||||||
|
type loginBody struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type codeBody struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
Code string `json:"code"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type codeReturn struct {
|
||||||
|
Access string `json:"access"`
|
||||||
|
Refresh string `json:"refresh"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AuthHandler) login(body loginBody, w http.ResponseWriter, r *http.Request) {
|
||||||
|
err := h.auth.CreateCode(body.Email)
|
||||||
|
if err != nil {
|
||||||
|
middleware.WriteErrorInternal(h.logger, "could not create a code", w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AuthHandler) code(body codeBody, w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := h.auth.UseCode(body.Email, body.Code); err != nil {
|
||||||
|
middleware.WriteErrorBadRequest(h.logger, "email or code are incorrect", w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: we should only keep emails around for a little bit.
|
||||||
|
// Time to first login should be less than 10 minutes.
|
||||||
|
// So actually, they shouldn't be written to our database.
|
||||||
|
if exists := h.user.DoesUserExist(r.Context(), body.Email); !exists {
|
||||||
|
h.user.Save(r.Context(), model.Users{
|
||||||
|
Email: body.Email,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
uuid, err := h.user.GetUserIdFromEmail(r.Context(), body.Email)
|
||||||
|
if err != nil {
|
||||||
|
middleware.WriteErrorBadRequest(h.logger, "failed to get user", w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh := middleware.CreateRefreshToken(uuid)
|
||||||
|
access := middleware.CreateAccessToken(uuid)
|
||||||
|
|
||||||
|
codeReturn := codeReturn{
|
||||||
|
Access: access,
|
||||||
|
Refresh: refresh,
|
||||||
|
}
|
||||||
|
|
||||||
|
middleware.WriteJsonOrError(h.logger, codeReturn, w)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AuthHandler) CreateRoutes(r chi.Router) {
|
||||||
|
h.logger.Info("Mounting auth router")
|
||||||
|
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Use(middleware.SetJson)
|
||||||
|
|
||||||
|
r.Post("/login", middleware.WithValidatedPost(h.login))
|
||||||
|
r.Post("/code", middleware.WithValidatedPost(h.code))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func CreateAuthHandler(db *sql.DB) AuthHandler {
|
||||||
|
userModel := models.NewUserModel(db)
|
||||||
|
logger := log.New(os.Stdout).WithPrefix("Auth")
|
||||||
|
|
||||||
|
mailer, err := CreateMailClient(logger)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
auth := CreateAuth(mailer)
|
||||||
|
|
||||||
|
return AuthHandler{
|
||||||
|
logger,
|
||||||
|
userModel,
|
||||||
|
auth,
|
||||||
|
}
|
||||||
|
}
|
@ -8,8 +8,10 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"screenmark/screenmark/agents"
|
"screenmark/screenmark/agents"
|
||||||
|
"screenmark/screenmark/middleware"
|
||||||
"screenmark/screenmark/models"
|
"screenmark/screenmark/models"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/charmbracelet/log"
|
"github.com/charmbracelet/log"
|
||||||
@ -17,13 +19,63 @@ import (
|
|||||||
"github.com/lib/pq"
|
"github.com/lib/pq"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Notification struct {
|
const (
|
||||||
|
IMAGE_TYPE = "image"
|
||||||
|
LIST_TYPE = "list"
|
||||||
|
)
|
||||||
|
|
||||||
|
type imageNotification struct {
|
||||||
|
Type string
|
||||||
|
|
||||||
ImageID uuid.UUID
|
ImageID uuid.UUID
|
||||||
ImageName string
|
ImageName string
|
||||||
|
|
||||||
Status string
|
Status string
|
||||||
}
|
}
|
||||||
|
|
||||||
func ListenNewImageEvents(db *sql.DB, notifier *Notifier[Notification]) {
|
type listNotification struct {
|
||||||
|
Type string
|
||||||
|
|
||||||
|
ListID uuid.UUID
|
||||||
|
Name string
|
||||||
|
|
||||||
|
Status string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Notification struct {
|
||||||
|
image *imageNotification
|
||||||
|
list *listNotification
|
||||||
|
}
|
||||||
|
|
||||||
|
func getImageNotification(image imageNotification) Notification {
|
||||||
|
return Notification{
|
||||||
|
image: &image,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getListNotification(list listNotification) Notification {
|
||||||
|
return Notification{
|
||||||
|
list: &list,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n Notification) MarshalJSON() ([]byte, error) {
|
||||||
|
if n.image != nil {
|
||||||
|
return json.Marshal(n.image)
|
||||||
|
}
|
||||||
|
|
||||||
|
if n.list != nil {
|
||||||
|
return json.Marshal(n.list)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("no image or list present")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *Notification) UnmarshalJSON(data []byte) error {
|
||||||
|
return fmt.Errorf("unimplemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func ListenNewImageEvents(db *sql.DB) {
|
||||||
listener := pq.NewListener(os.Getenv("DB_CONNECTION"), time.Second, time.Second, func(event pq.ListenerEventType, err error) {
|
listener := pq.NewListener(os.Getenv("DB_CONNECTION"), time.Second, time.Second, func(event pq.ListenerEventType, err error) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
@ -64,14 +116,24 @@ func ListenNewImageEvents(db *sql.DB, notifier *Notifier[Notification]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
descriptionAgent := agents.NewDescriptionAgent(createLogger("Description 📝", splitWriter), imageModel)
|
descriptionAgent := agents.NewDescriptionAgent(createLogger("Description 📝", splitWriter), imageModel)
|
||||||
err = descriptionAgent.Describe(createLogger("Description 📓", splitWriter), image.Image.ID, image.Image.ImageName, image.Image.Image)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
log.Error(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
listAgent := agents.NewListAgent(createLogger("Lists 🖋️", splitWriter), listModel)
|
listAgent := agents.NewListAgent(createLogger("Lists 🖋️", splitWriter), listModel)
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(2)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
|
||||||
|
descriptionAgent.Describe(createLogger("Description 📓", splitWriter), image.Image.ID, image.Image.ImageName, image.Image.Image)
|
||||||
|
}()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
|
||||||
listAgent.RunAgent(image.UserID, image.ImageID, image.Image.ImageName, image.Image.Image)
|
listAgent.RunAgent(image.UserID, image.ImageID, image.Image.ImageName, image.Image.Image)
|
||||||
|
}()
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
_, err = imageModel.FinishProcessing(ctx, image.ID)
|
_, err = imageModel.FinishProcessing(ctx, image.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -116,11 +178,12 @@ func ListenProcessingImageStatus(db *sql.DB, images models.ImageModel, notifier
|
|||||||
|
|
||||||
logger.Info("Update", "id", imageStringUuid, "status", status)
|
logger.Info("Update", "id", imageStringUuid, "status", status)
|
||||||
|
|
||||||
notification := Notification{
|
notification := getImageNotification(imageNotification{
|
||||||
|
Type: IMAGE_TYPE,
|
||||||
ImageID: processingImage.ImageID,
|
ImageID: processingImage.ImageID,
|
||||||
ImageName: processingImage.Image.ImageName,
|
ImageName: processingImage.Image.ImageName,
|
||||||
Status: status,
|
Status: status,
|
||||||
}
|
})
|
||||||
|
|
||||||
if err := notifier.SendAndCreate(processingImage.UserID.String(), notification); err != nil {
|
if err := notifier.SendAndCreate(processingImage.UserID.String(), notification); err != nil {
|
||||||
logger.Error(err)
|
logger.Error(err)
|
||||||
@ -128,6 +191,107 @@ func ListenProcessingImageStatus(db *sql.DB, images models.ImageModel, notifier
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ListenNewStackEvents(db *sql.DB) {
|
||||||
|
listener := pq.NewListener(os.Getenv("DB_CONNECTION"), time.Second, time.Second, func(event pq.ListenerEventType, err error) {
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
defer listener.Close()
|
||||||
|
|
||||||
|
stackModel := models.NewListModel(db)
|
||||||
|
|
||||||
|
newStacksLogger := createLogger("New Stacks 🤖", os.Stdout)
|
||||||
|
newStacksLogger.SetLevel(log.DebugLevel)
|
||||||
|
|
||||||
|
err := listener.Listen("new_stack")
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for parameters := range listener.Notify {
|
||||||
|
stackID := uuid.MustParse(parameters.Extra)
|
||||||
|
|
||||||
|
newStacksLogger.Debug("Starting processing stack", "StackID", stackID)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
stack, err := stackModel.GetProcessing(ctx, stackID)
|
||||||
|
if err != nil {
|
||||||
|
newStacksLogger.Error("failed to get processing", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := stackModel.StartProcessing(ctx, stackID); err != nil {
|
||||||
|
newStacksLogger.Error("failed to start processing", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
listAgent := agents.NewCreateListAgent(newStacksLogger, stackModel)
|
||||||
|
userListRequest := fmt.Sprintf("title=%s,fields=%s", stack.Title, stack.Fields)
|
||||||
|
|
||||||
|
err = listAgent.CreateList(newStacksLogger, stack.UserID, userListRequest)
|
||||||
|
if err != nil {
|
||||||
|
newStacksLogger.Error("running agent", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := stackModel.EndProcessing(ctx, stackID); err != nil {
|
||||||
|
newStacksLogger.Error("failed to finish processing", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
newStacksLogger.Debug("Finished processing stack", "StackID", stackID)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ListenProcessingStackStatus(db *sql.DB, stacks models.ListModel, notifier *Notifier[Notification]) {
|
||||||
|
listener := pq.NewListener(os.Getenv("DB_CONNECTION"), time.Second, time.Second, func(event pq.ListenerEventType, err error) {
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
defer listener.Close()
|
||||||
|
|
||||||
|
logger := createLogger("Stack Status 📊", os.Stdout)
|
||||||
|
|
||||||
|
if err := listener.Listen("new_processing_stack_status"); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for data := range listener.Notify {
|
||||||
|
stackStringUUID := data.Extra[0:36]
|
||||||
|
status := data.Extra[36:]
|
||||||
|
|
||||||
|
stackUUID, err := uuid.Parse(stackStringUUID)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error(err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
processingStack, err := stacks.GetToProcess(context.Background(), stackUUID)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("GetToProcess failed", "err", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Info("Update", "id", stackStringUUID, "status", status)
|
||||||
|
|
||||||
|
notification := getListNotification(listNotification{
|
||||||
|
Type: LIST_TYPE,
|
||||||
|
Name: processingStack.Title,
|
||||||
|
ListID: stackUUID,
|
||||||
|
Status: status,
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := notifier.SendAndCreate(processingStack.UserID.String(), notification); err != nil {
|
||||||
|
logger.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* TODO: We have channels open every a user sends an image.
|
* TODO: We have channels open every a user sends an image.
|
||||||
* We never close these channels.
|
* We never close these channels.
|
||||||
@ -140,7 +304,7 @@ func CreateEventsHandler(notifier *Notifier[Notification]) http.HandlerFunc {
|
|||||||
userSplitters := make(map[string]*ChannelSplitter[Notification])
|
userSplitters := make(map[string]*ChannelSplitter[Notification])
|
||||||
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
_userId := r.Context().Value(USER_ID).(uuid.UUID)
|
_userId := r.Context().Value(middleware.USER_ID).(uuid.UUID)
|
||||||
if _userId == uuid.Nil {
|
if _userId == uuid.Nil {
|
||||||
w.WriteHeader(http.StatusUnauthorized)
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
@ -188,7 +352,8 @@ func CreateEventsHandler(notifier *Notifier[Notification]) http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("Sending msg %s\n", msg)
|
fmt.Printf("Sending msg %s\n", msgString)
|
||||||
|
|
||||||
fmt.Fprintf(w, "event: data\ndata: %s\n\n", string(msgString))
|
fmt.Fprintf(w, "event: data\ndata: %s\n\n", string(msgString))
|
||||||
w.(http.Flusher).Flush()
|
w.(http.Flusher).Flush()
|
||||||
}
|
}
|
||||||
|
170
backend/images/handler.go
Normal file
170
backend/images/handler.go
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
package images
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"screenmark/screenmark/.gen/haystack/haystack/model"
|
||||||
|
"screenmark/screenmark/middleware"
|
||||||
|
"screenmark/screenmark/models"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/log"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ImageHandler struct {
|
||||||
|
logger *log.Logger
|
||||||
|
imageModel models.ImageModel
|
||||||
|
userModel models.UserModel
|
||||||
|
}
|
||||||
|
|
||||||
|
type ImagesReturn struct {
|
||||||
|
UserImages []models.UserImageWithImage `json:"userImages"`
|
||||||
|
ProcessingImages []models.UserProcessingImage `json:"processingImages"`
|
||||||
|
Lists []models.ListsWithImages `json:"lists"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ImageHandler) serveImage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
imageId, err := middleware.GetPathParamID(h.logger, "id", w, r)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
image, err := h.imageModel.Get(r.Context(), imageId)
|
||||||
|
if err != nil {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
fmt.Fprintf(w, "Could not get image")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: this could be part of the db table
|
||||||
|
extension := filepath.Ext(image.ImageName)
|
||||||
|
if len(extension) == 0 {
|
||||||
|
// Same hack
|
||||||
|
extension = "png"
|
||||||
|
}
|
||||||
|
extension = extension[1:]
|
||||||
|
|
||||||
|
w.Header().Add("Content-Type", "image/"+extension)
|
||||||
|
w.Write(image.Image)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ImageHandler) listImages(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userId, err := middleware.GetUserID(r.Context(), h.logger, w)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
images, err := h.userModel.GetUserImages(r.Context(), userId)
|
||||||
|
if err != nil {
|
||||||
|
middleware.WriteErrorInternal(h.logger, "could not get user images", w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
processingImages, err := h.imageModel.GetProcessing(r.Context(), userId)
|
||||||
|
if err != nil {
|
||||||
|
middleware.WriteErrorInternal(h.logger, "could not get processing images", w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
listsWithImages, err := h.userModel.ListWithImages(r.Context(), userId)
|
||||||
|
if err != nil {
|
||||||
|
middleware.WriteErrorInternal(h.logger, "could not get lists with images", w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
imagesReturn := ImagesReturn{
|
||||||
|
UserImages: images,
|
||||||
|
ProcessingImages: processingImages,
|
||||||
|
Lists: listsWithImages,
|
||||||
|
}
|
||||||
|
|
||||||
|
middleware.WriteJsonOrError(h.logger, imagesReturn, w)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ImageHandler) uploadImage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
imageName := chi.URLParam(r, "name")
|
||||||
|
if len(imageName) == 0 {
|
||||||
|
middleware.WriteErrorBadRequest(h.logger, "you need to provide a name in the path", w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userId, err := middleware.GetUserID(r.Context(), h.logger, w)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
contentType := r.Header.Get("Content-Type")
|
||||||
|
|
||||||
|
image := make([]byte, 0)
|
||||||
|
switch contentType {
|
||||||
|
case "application/base64":
|
||||||
|
decoder := base64.NewDecoder(base64.StdEncoding, r.Body)
|
||||||
|
buf := &bytes.Buffer{}
|
||||||
|
|
||||||
|
_, err := io.Copy(buf, decoder)
|
||||||
|
if err != nil {
|
||||||
|
middleware.WriteErrorBadRequest(h.logger, "base64 decoding failed", w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
image = buf.Bytes()
|
||||||
|
case "application/oclet-stream", "image/png":
|
||||||
|
bodyData, err := io.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
middleware.WriteErrorBadRequest(h.logger, "binary data reading failed", w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// TODO: check headers
|
||||||
|
|
||||||
|
image = bodyData
|
||||||
|
default:
|
||||||
|
middleware.WriteErrorBadRequest(h.logger, "unsupported content type, need octet-stream or base64", w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userImage, err := h.imageModel.Process(r.Context(), userId, model.Image{
|
||||||
|
Image: image,
|
||||||
|
ImageName: imageName,
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
middleware.WriteErrorInternal(h.logger, "could not save image to DB", w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
middleware.WriteJsonOrError(h.logger, userImage, w)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ImageHandler) CreateRoutes(r chi.Router) {
|
||||||
|
h.logger.Info("Mounting image router")
|
||||||
|
|
||||||
|
// Public route for serving images (not protected)
|
||||||
|
r.Get("/{id}", h.serveImage)
|
||||||
|
|
||||||
|
// Protected routes
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Use(middleware.ProtectedRoute)
|
||||||
|
r.Use(middleware.SetJson)
|
||||||
|
|
||||||
|
r.Get("/", h.listImages)
|
||||||
|
r.Post("/{name}", h.uploadImage)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func CreateImageHandler(db *sql.DB) ImageHandler {
|
||||||
|
imageModel := models.NewImageModel(db)
|
||||||
|
userModel := models.NewUserModel(db)
|
||||||
|
logger := log.New(os.Stdout).WithPrefix("Images")
|
||||||
|
|
||||||
|
return ImageHandler{
|
||||||
|
logger: logger,
|
||||||
|
imageModel: imageModel,
|
||||||
|
userModel: userModel,
|
||||||
|
}
|
||||||
|
}
|
796
backend/integration_test.go
Normal file
796
backend/integration_test.go
Normal file
@ -0,0 +1,796 @@
|
|||||||
|
// Integration Tests for Haystack Backend
|
||||||
|
//
|
||||||
|
// These tests provide comprehensive end-to-end testing of all API endpoints.
|
||||||
|
//
|
||||||
|
// Requirements:
|
||||||
|
// - Docker must be installed and running
|
||||||
|
// - PostgreSQL Docker image will be automatically pulled and started
|
||||||
|
//
|
||||||
|
// To run the integration tests:
|
||||||
|
//
|
||||||
|
// 1. Start Docker daemon
|
||||||
|
// 2. Run: go test -v ./integration_test.go
|
||||||
|
//
|
||||||
|
// The tests will:
|
||||||
|
// - Start a PostgreSQL container on port 5433
|
||||||
|
// - Set up the database schema
|
||||||
|
// - Test all auth, stack, and image endpoints
|
||||||
|
// - Clean up the container after tests complete
|
||||||
|
//
|
||||||
|
// Note: These tests require Docker and will be skipped if Docker is not available.
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"screenmark/screenmark/middleware"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
testDBName = "test_haystack"
|
||||||
|
testDBUser = "test_user"
|
||||||
|
testDBPassword = "test_password"
|
||||||
|
testDBHost = "localhost"
|
||||||
|
testDBPort = "5433"
|
||||||
|
testDBSSLMode = "disable"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TestUser struct {
|
||||||
|
ID uuid.UUID
|
||||||
|
Email string
|
||||||
|
Token string
|
||||||
|
}
|
||||||
|
|
||||||
|
type TestContext struct {
|
||||||
|
db *sql.DB
|
||||||
|
router chi.Router
|
||||||
|
server *httptest.Server
|
||||||
|
users []TestUser
|
||||||
|
cleanup func()
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupTestDatabase() (*sql.DB, func(), error) {
|
||||||
|
// Check if Docker daemon is running
|
||||||
|
checkCmd := exec.Command("docker", "info")
|
||||||
|
if err := checkCmd.Run(); err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("docker daemon is not running: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start PostgreSQL container
|
||||||
|
containerName := "test_postgres_haystack"
|
||||||
|
|
||||||
|
// Clean up any existing container
|
||||||
|
exec.Command("docker", "rm", "-f", containerName).Run()
|
||||||
|
|
||||||
|
// Start new PostgreSQL container
|
||||||
|
cmd := exec.Command("docker", "run", "-d",
|
||||||
|
"--name", containerName,
|
||||||
|
"-e", "POSTGRES_DB="+testDBName,
|
||||||
|
"-e", "POSTGRES_USER="+testDBUser,
|
||||||
|
"-e", "POSTGRES_PASSWORD="+testDBPassword,
|
||||||
|
"-p", testDBPort+":5432",
|
||||||
|
"postgres:15-alpine",
|
||||||
|
)
|
||||||
|
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("failed to start postgres container: %w, output: %s", err, string(output))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for database to be ready with retries
|
||||||
|
maxRetries := 15
|
||||||
|
for i := range maxRetries {
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
|
||||||
|
// Test connection
|
||||||
|
connStr := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=%s",
|
||||||
|
testDBHost, testDBPort, testDBUser, testDBPassword, testDBName, testDBSSLMode)
|
||||||
|
|
||||||
|
testDB, testErr := sql.Open("postgres", connStr)
|
||||||
|
if testErr == nil {
|
||||||
|
if pingErr := testDB.Ping(); pingErr == nil {
|
||||||
|
testDB.Close()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
testDB.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
if i == maxRetries-1 {
|
||||||
|
return nil, nil, fmt.Errorf("database failed to become ready after %d retries", maxRetries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect to database
|
||||||
|
connStr := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=%s",
|
||||||
|
testDBHost, testDBPort, testDBUser, testDBPassword, testDBName, testDBSSLMode)
|
||||||
|
|
||||||
|
db, err := sql.Open("postgres", connStr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("failed to connect to test database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test connection
|
||||||
|
if err := db.Ping(); err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("failed to ping test database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load and execute schema
|
||||||
|
schema, err := os.ReadFile("schema.sql")
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("failed to read schema file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := db.Exec(string(schema)); err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("failed to execute schema: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup function
|
||||||
|
cleanup := func() {
|
||||||
|
db.Close()
|
||||||
|
exec.Command("docker", "rm", "-f", containerName).Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
return db, cleanup, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupTestContext(t *testing.T) *TestContext {
|
||||||
|
// Set environment variables for test environment
|
||||||
|
connStr := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=%s",
|
||||||
|
testDBHost, testDBPort, testDBUser, testDBPassword, testDBName, testDBSSLMode)
|
||||||
|
|
||||||
|
originalDBConn := os.Getenv("DB_CONNECTION")
|
||||||
|
originalTestEnv := os.Getenv("GO_TEST_ENVIRONMENT")
|
||||||
|
|
||||||
|
os.Setenv("DB_CONNECTION", connStr)
|
||||||
|
os.Setenv("GO_TEST_ENVIRONMENT", "true")
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
if originalDBConn != "" {
|
||||||
|
os.Setenv("DB_CONNECTION", originalDBConn)
|
||||||
|
} else {
|
||||||
|
os.Unsetenv("DB_CONNECTION")
|
||||||
|
}
|
||||||
|
if originalTestEnv != "" {
|
||||||
|
os.Setenv("GO_TEST_ENVIRONMENT", originalTestEnv)
|
||||||
|
} else {
|
||||||
|
os.Unsetenv("GO_TEST_ENVIRONMENT")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
tc := &TestContext{}
|
||||||
|
|
||||||
|
db, cleanup, err := setupTestDatabase()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to setup test database: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
router := setupRouter(db)
|
||||||
|
server := httptest.NewServer(router)
|
||||||
|
|
||||||
|
tc.db = db
|
||||||
|
tc.router = router
|
||||||
|
tc.server = server
|
||||||
|
tc.cleanup = func() {
|
||||||
|
server.Close()
|
||||||
|
cleanup()
|
||||||
|
}
|
||||||
|
|
||||||
|
return tc
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tc *TestContext) createTestUser(email string) TestUser {
|
||||||
|
// Insert user into database
|
||||||
|
var userID uuid.UUID
|
||||||
|
err := tc.db.QueryRow("INSERT INTO haystack.users (email) VALUES ($1) RETURNING id", email).Scan(&userID)
|
||||||
|
if err != nil {
|
||||||
|
panic(fmt.Sprintf("Failed to create test user: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create access token for the user
|
||||||
|
accessToken := middleware.CreateAccessToken(userID)
|
||||||
|
|
||||||
|
user := TestUser{
|
||||||
|
ID: userID,
|
||||||
|
Email: email,
|
||||||
|
Token: accessToken,
|
||||||
|
}
|
||||||
|
|
||||||
|
tc.users = append(tc.users, user)
|
||||||
|
return user
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tc *TestContext) makeRequest(t *testing.T, method, path, token string, body io.Reader) *http.Response {
|
||||||
|
url := tc.server.URL + path
|
||||||
|
req, err := http.NewRequest(method, url, body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create request: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if token != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
}
|
||||||
|
|
||||||
|
if body != nil {
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 10 * time.Second}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to make request: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tc *TestContext) makeJSONRequest(t *testing.T, method, path, token string, data any) *http.Response {
|
||||||
|
var body io.Reader
|
||||||
|
if data != nil {
|
||||||
|
jsonData, err := json.Marshal(data)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to marshal JSON: %v", err)
|
||||||
|
}
|
||||||
|
body = bytes.NewReader(jsonData)
|
||||||
|
}
|
||||||
|
|
||||||
|
return tc.makeRequest(t, method, path, token, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Comprehensive integration test suite - single database setup for all tests
|
||||||
|
func TestAllRoutes(t *testing.T) {
|
||||||
|
tc := setupTestContext(t)
|
||||||
|
defer tc.cleanup()
|
||||||
|
|
||||||
|
// Create test users for different test scenarios
|
||||||
|
stackUser := tc.createTestUser("stacktest@example.com")
|
||||||
|
imageUser := tc.createTestUser("imagetest@example.com")
|
||||||
|
flowUser := tc.createTestUser("flowtest@example.com")
|
||||||
|
|
||||||
|
t.Run("Auth Routes", func(t *testing.T) {
|
||||||
|
t.Run("Login endpoint", func(t *testing.T) {
|
||||||
|
loginData := map[string]string{
|
||||||
|
"email": "test@example.com",
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := tc.makeJSONRequest(t, "POST", "/auth/login", "", loginData)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Errorf("Expected status 200, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Code endpoint with valid email", func(t *testing.T) {
|
||||||
|
// First create a login request to set up the email
|
||||||
|
loginData := map[string]string{
|
||||||
|
"email": "test@example.com",
|
||||||
|
}
|
||||||
|
tc.makeJSONRequest(t, "POST", "/auth/login", "", loginData)
|
||||||
|
|
||||||
|
// Then try to use a code (this will fail with invalid code, but tests the endpoint)
|
||||||
|
codeData := map[string]string{
|
||||||
|
"email": "test@example.com",
|
||||||
|
"code": "invalid",
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := tc.makeJSONRequest(t, "POST", "/auth/code", "", codeData)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
// The auth system creates a user for new emails, so this returns 200
|
||||||
|
// We're testing that the endpoint works, not necessarily the code validation
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Errorf("Expected status 200 for code endpoint, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Protected route without token", func(t *testing.T) {
|
||||||
|
resp := tc.makeRequest(t, "GET", "/images/image", "", nil)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusUnauthorized {
|
||||||
|
t.Errorf("Expected status 401 for protected route without token, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Stack Routes", func(t *testing.T) {
|
||||||
|
t.Run("Get stacks without authentication", func(t *testing.T) {
|
||||||
|
resp := tc.makeRequest(t, "GET", "/stacks/", "", nil)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusUnauthorized {
|
||||||
|
t.Errorf("Expected status 401, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Get stacks with authentication", func(t *testing.T) {
|
||||||
|
resp := tc.makeRequest(t, "GET", "/stacks/", stackUser.Token, nil)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Errorf("Expected status 200, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var stacks []interface{}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&stacks); err != nil {
|
||||||
|
t.Errorf("Failed to decode response: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Create stack", func(t *testing.T) {
|
||||||
|
stackData := map[string]string{
|
||||||
|
"title": "Test Stack",
|
||||||
|
"fields": "name,description,value",
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := tc.makeJSONRequest(t, "POST", "/stacks/", stackUser.Token, stackData)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Errorf("Expected status 200, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Get stack items with invalid ID", func(t *testing.T) {
|
||||||
|
resp := tc.makeRequest(t, "GET", "/stacks/invalid-id", stackUser.Token, nil)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusBadRequest {
|
||||||
|
t.Errorf("Expected status 400 for invalid ID, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Delete stack without authentication", func(t *testing.T) {
|
||||||
|
fakeUUID := uuid.New()
|
||||||
|
resp := tc.makeRequest(t, "DELETE", "/stacks/"+fakeUUID.String(), "", nil)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusUnauthorized {
|
||||||
|
t.Errorf("Expected status 401 for unauthenticated delete, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Delete stack with invalid ID", func(t *testing.T) {
|
||||||
|
resp := tc.makeRequest(t, "DELETE", "/stacks/invalid-id", stackUser.Token, nil)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusBadRequest {
|
||||||
|
t.Errorf("Expected status 400 for invalid ID, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Delete non-existent stack", func(t *testing.T) {
|
||||||
|
fakeUUID := uuid.New()
|
||||||
|
resp := tc.makeRequest(t, "DELETE", "/stacks/"+fakeUUID.String(), stackUser.Token, nil)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusBadRequest {
|
||||||
|
t.Errorf("Expected status 400 for non-existent stack, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Create and delete stack successfully", func(t *testing.T) {
|
||||||
|
// First create a stack
|
||||||
|
stackData := map[string]string{
|
||||||
|
"title": "Stack to Delete",
|
||||||
|
"fields": "name,description,value",
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := tc.makeJSONRequest(t, "POST", "/stacks/", stackUser.Token, stackData)
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Errorf("Failed to create stack for deletion test, got %d", resp.StatusCode)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the list of stacks to find the created stack ID
|
||||||
|
resp = tc.makeRequest(t, "GET", "/stacks/", stackUser.Token, nil)
|
||||||
|
|
||||||
|
var stacks []map[string]interface{}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&stacks); err != nil {
|
||||||
|
t.Errorf("Failed to decode stacks response: %v", err)
|
||||||
|
resp.Body.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
|
if len(stacks) == 0 {
|
||||||
|
t.Errorf("No stacks found after creation")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the stack we just created
|
||||||
|
var stackToDelete map[string]interface{}
|
||||||
|
for _, stack := range stacks {
|
||||||
|
if name, ok := stack["Name"].(string); ok && name == "Stack to Delete" {
|
||||||
|
stackToDelete = stack
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if stackToDelete == nil {
|
||||||
|
t.Errorf("Could not find created stack")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
stackID, ok := stackToDelete["ID"].(string)
|
||||||
|
if !ok {
|
||||||
|
t.Errorf("Stack ID not found or not a string")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now delete the stack
|
||||||
|
resp = tc.makeRequest(t, "DELETE", "/stacks/"+stackID, stackUser.Token, nil)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Errorf("Expected status 200 for successful delete, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the stack is gone by trying to get it again
|
||||||
|
resp = tc.makeRequest(t, "GET", "/stacks/", stackUser.Token, nil)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
var stacksAfterDelete []map[string]interface{}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&stacksAfterDelete); err != nil {
|
||||||
|
t.Errorf("Failed to decode stacks response after delete: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check that the deleted stack is no longer in the list
|
||||||
|
for _, stack := range stacksAfterDelete {
|
||||||
|
if id, ok := stack["ID"].(string); ok && id == stackID {
|
||||||
|
t.Errorf("Stack still exists after deletion")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Delete stack belonging to different user", func(t *testing.T) {
|
||||||
|
// Create a stack with stackUser
|
||||||
|
stackData := map[string]string{
|
||||||
|
"title": "Other User's Stack",
|
||||||
|
"fields": "name,description,value",
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := tc.makeJSONRequest(t, "POST", "/stacks/", stackUser.Token, stackData)
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Errorf("Failed to create stack for ownership test, got %d", resp.StatusCode)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the stack ID
|
||||||
|
resp = tc.makeRequest(t, "GET", "/stacks/", stackUser.Token, nil)
|
||||||
|
|
||||||
|
var stacks []map[string]interface{}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&stacks); err != nil {
|
||||||
|
t.Errorf("Failed to decode stacks response: %v", err)
|
||||||
|
resp.Body.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
|
var stackID string
|
||||||
|
for _, stack := range stacks {
|
||||||
|
if name, ok := stack["Name"].(string); ok && name == "Other User's Stack" {
|
||||||
|
if id, ok := stack["ID"].(string); ok {
|
||||||
|
stackID = id
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if stackID == "" {
|
||||||
|
t.Errorf("Could not find created stack ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to delete the stack with a different user (imageUser)
|
||||||
|
resp = tc.makeRequest(t, "DELETE", "/stacks/"+stackID, imageUser.Token, nil)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusBadRequest {
|
||||||
|
t.Errorf("Expected status 400 when deleting another user's stack, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Image Routes", func(t *testing.T) {
|
||||||
|
t.Run("Get images without authentication", func(t *testing.T) {
|
||||||
|
resp := tc.makeRequest(t, "GET", "/images/", "", nil)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusUnauthorized {
|
||||||
|
t.Errorf("Expected status 401, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Get images with authentication", func(t *testing.T) {
|
||||||
|
resp := tc.makeRequest(t, "GET", "/images/", imageUser.Token, nil)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Errorf("Expected status 200, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var imageData interface{}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&imageData); err != nil {
|
||||||
|
t.Errorf("Failed to decode response: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Upload image with base64", func(t *testing.T) {
|
||||||
|
// Create a simple valid base64 string for testing
|
||||||
|
testImageBase64 := "dGVzdCBkYXRh" // "test data" in base64
|
||||||
|
|
||||||
|
req, err := http.NewRequest("POST", tc.server.URL+"/images/test.png", strings.NewReader(testImageBase64))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create request: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Authorization", "Bearer "+imageUser.Token)
|
||||||
|
req.Header.Set("Content-Type", "application/base64")
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 10 * time.Second}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to make request: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
// The API might return 200 for successful operations
|
||||||
|
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
||||||
|
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||||
|
t.Errorf("Expected status 200 or 201, got %d. Response: %s", resp.StatusCode, string(bodyBytes))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Upload image with binary data", func(t *testing.T) {
|
||||||
|
// Create a small test image (minimal PNG)
|
||||||
|
testImageBinary := []byte{
|
||||||
|
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D,
|
||||||
|
0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
|
||||||
|
0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, 0xDE, 0x00, 0x00, 0x00,
|
||||||
|
0x0C, 0x49, 0x44, 0x41, 0x54, 0x08, 0x99, 0x01, 0x01, 0x00, 0x00, 0x00,
|
||||||
|
0x00, 0x00, 0x37, 0x6E, 0xF9, 0x5F, 0x0F, 0x00, 0x00, 0x00, 0x00, 0x49,
|
||||||
|
0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82,
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest("POST", tc.server.URL+"/images/test2.png", bytes.NewReader(testImageBinary))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create request: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Authorization", "Bearer "+imageUser.Token)
|
||||||
|
req.Header.Set("Content-Type", "image/png")
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 10 * time.Second}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to make request: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
// The API might return 200 for successful operations
|
||||||
|
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
||||||
|
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||||
|
t.Errorf("Expected status 200 or 201, got %d. Response: %s", resp.StatusCode, string(bodyBytes))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Upload image without name", func(t *testing.T) {
|
||||||
|
resp := tc.makeRequest(t, "POST", "/images/", imageUser.Token, nil)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
// Route pattern doesn't match empty names, so returns 404
|
||||||
|
if resp.StatusCode != http.StatusNotFound {
|
||||||
|
t.Errorf("Expected status 404 for missing name, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Serve non-existent image", func(t *testing.T) {
|
||||||
|
fakeUUID := uuid.New()
|
||||||
|
resp := tc.makeRequest(t, "GET", "/images/"+fakeUUID.String(), "", nil)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusNotFound {
|
||||||
|
t.Errorf("Expected status 404 for non-existent image, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Complete User Flow", func(t *testing.T) {
|
||||||
|
// Step 1: Test authentication is working
|
||||||
|
resp := tc.makeRequest(t, "GET", "/images/", flowUser.Token, nil)
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Errorf("Authentication failed, expected 200, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
|
// Step 2: Upload an image
|
||||||
|
testImageBinary := []byte{
|
||||||
|
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D,
|
||||||
|
0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
|
||||||
|
0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, 0xDE, 0x00, 0x00, 0x00,
|
||||||
|
0x0C, 0x49, 0x44, 0x41, 0x54, 0x08, 0x99, 0x01, 0x01, 0x00, 0x00, 0x00,
|
||||||
|
0x00, 0x00, 0x37, 0x6E, 0xF9, 0x5F, 0x0F, 0x00, 0x00, 0x00, 0x00, 0x49,
|
||||||
|
0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82,
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest("POST", tc.server.URL+"/images/test_flow.png", bytes.NewReader(testImageBinary))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create upload request: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Authorization", "Bearer "+flowUser.Token)
|
||||||
|
req.Header.Set("Content-Type", "image/png")
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 10 * time.Second}
|
||||||
|
resp, err = client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to upload image: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The API returns 200 for successful image uploads
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||||
|
t.Errorf("Image upload failed, expected 200, got %d. Response: %s", resp.StatusCode, string(bodyBytes))
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
|
// Step 3: Verify image appears in user's image list
|
||||||
|
resp = tc.makeRequest(t, "GET", "/images/", flowUser.Token, nil)
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Errorf("Failed to get user images, expected 200, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var imageData map[string]interface{}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&imageData); err != nil {
|
||||||
|
t.Errorf("Failed to decode image list: %v", err)
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
|
// Check that we have user images
|
||||||
|
if userImages, ok := imageData["userImages"].([]interface{}); ok {
|
||||||
|
if len(userImages) == 0 {
|
||||||
|
t.Log("Warning: No user images found, but upload succeeded")
|
||||||
|
} else {
|
||||||
|
t.Logf("Found %d user images", len(userImages))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: Test stack creation
|
||||||
|
stackData := map[string]string{
|
||||||
|
"title": "Integration Test Stack",
|
||||||
|
"fields": "name,description,value",
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = tc.makeJSONRequest(t, "POST", "/stacks/", flowUser.Token, stackData)
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Errorf("Stack creation failed, expected 200, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
|
// Step 5: Verify stack appears in user's stack list
|
||||||
|
resp = tc.makeRequest(t, "GET", "/stacks/", flowUser.Token, nil)
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Errorf("Failed to get user stacks, expected 200, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var stacks []interface{}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&stacks); err != nil {
|
||||||
|
t.Errorf("Failed to decode stack list: %v", err)
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
|
if len(stacks) == 0 {
|
||||||
|
t.Log("Warning: No stacks found, but creation succeeded")
|
||||||
|
} else {
|
||||||
|
t.Logf("Found %d stacks", len(stacks))
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Log("Complete user flow test passed!")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple test that doesn't require Docker
|
||||||
|
func TestIntegrationTestSetup(t *testing.T) {
|
||||||
|
// This test verifies that the test structure is correct
|
||||||
|
// It doesn't require Docker to be running
|
||||||
|
|
||||||
|
t.Run("Test structure validation", func(t *testing.T) {
|
||||||
|
// This test verifies that the test structure is correct
|
||||||
|
// It doesn't require Docker to be running
|
||||||
|
|
||||||
|
// Verify that our test types are properly defined
|
||||||
|
var _ TestUser
|
||||||
|
var _ TestContext
|
||||||
|
|
||||||
|
// Verify that our constants are defined
|
||||||
|
if testDBName == "" {
|
||||||
|
t.Error("testDBName constant is not defined")
|
||||||
|
}
|
||||||
|
|
||||||
|
if testDBPort == "" {
|
||||||
|
t.Error("testDBPort constant is not defined")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Log("Test structure is valid")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Database and router setup", func(t *testing.T) {
|
||||||
|
// This test verifies that the database and router can be set up without SSL errors
|
||||||
|
tc := setupTestContext(t)
|
||||||
|
defer tc.cleanup()
|
||||||
|
|
||||||
|
// Verify that the router was created successfully
|
||||||
|
if tc.router == nil {
|
||||||
|
t.Error("Router was not created successfully")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify that the server was created successfully
|
||||||
|
if tc.server == nil {
|
||||||
|
t.Error("Server was not created successfully")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify that the database connection is working
|
||||||
|
if err := tc.db.Ping(); err != nil {
|
||||||
|
t.Errorf("Database connection failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Log("Database and router setup successful - no SSL errors!")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Docker availability check", func(t *testing.T) {
|
||||||
|
// Check if Docker is available but don't fail the test
|
||||||
|
if _, err := exec.LookPath("docker"); err != nil {
|
||||||
|
t.Skip("Docker not found, skipping Docker-dependent tests")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if Docker daemon is running
|
||||||
|
checkCmd := exec.Command("docker", "info")
|
||||||
|
if err := checkCmd.Run(); err != nil {
|
||||||
|
t.Skip("Docker daemon is not running, skipping Docker-dependent tests")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Log("Docker is available and running")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMain(m *testing.M) {
|
||||||
|
// Check if Docker is available
|
||||||
|
if _, err := exec.LookPath("docker"); err != nil {
|
||||||
|
fmt.Println("Docker not found, skipping integration tests")
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if Docker daemon is running
|
||||||
|
checkCmd := exec.Command("docker", "info")
|
||||||
|
if err := checkCmd.Run(); err != nil {
|
||||||
|
fmt.Println("Docker daemon is not running, skipping integration tests")
|
||||||
|
fmt.Println("To run integration tests, start Docker daemon and try again")
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run tests
|
||||||
|
code := m.Run()
|
||||||
|
os.Exit(code)
|
||||||
|
}
|
380
backend/main.go
380
backend/main.go
@ -1,32 +1,14 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"path/filepath"
|
"os"
|
||||||
"screenmark/screenmark/.gen/haystack/haystack/model"
|
|
||||||
"screenmark/screenmark/agents/client"
|
|
||||||
"screenmark/screenmark/models"
|
"screenmark/screenmark/models"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"github.com/joho/godotenv"
|
"github.com/joho/godotenv"
|
||||||
)
|
)
|
||||||
|
|
||||||
type TestAiClient struct {
|
|
||||||
ImageInfo client.ImageMessageContent
|
|
||||||
}
|
|
||||||
|
|
||||||
func (client TestAiClient) GetImageInfo(imageName string, imageData []byte) (client.ImageMessageContent, error) {
|
|
||||||
return client.ImageInfo, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
err := godotenv.Load()
|
err := godotenv.Load()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -38,356 +20,20 @@ func main() {
|
|||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
imageModel := models.NewImageModel(db)
|
router := setupRouter(db)
|
||||||
userModel := models.NewUserModel(db)
|
|
||||||
|
|
||||||
mail, err := CreateMailClient()
|
port, exists := os.LookupEnv("PORT")
|
||||||
|
if !exists {
|
||||||
|
panic("no port can be found")
|
||||||
|
}
|
||||||
|
|
||||||
|
portWithColon := fmt.Sprintf(":%s", port)
|
||||||
|
|
||||||
|
logger := createLogger("Main", os.Stdout)
|
||||||
|
|
||||||
|
logger.Info("Serving router", "port", portWithColon)
|
||||||
|
err = http.ListenAndServe(portWithColon, router)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
auth := CreateAuth(mail)
|
|
||||||
|
|
||||||
notifier := NewNotifier[Notification](10)
|
|
||||||
|
|
||||||
go ListenNewImageEvents(db, ¬ifier)
|
|
||||||
go ListenProcessingImageStatus(db, imageModel, ¬ifier)
|
|
||||||
|
|
||||||
r := chi.NewRouter()
|
|
||||||
|
|
||||||
r.Use(middleware.Logger)
|
|
||||||
r.Use(CorsMiddleware)
|
|
||||||
r.Options("/*", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Temporarily not in protect route because we aren't using cookies.
|
|
||||||
// Therefore they don't get automatically attached to the request.
|
|
||||||
// So <img src=""> cannot send the tokensend the token
|
|
||||||
r.Get("/image/{id}", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
stringImageId := r.PathValue("id")
|
|
||||||
// userId := r.Context().Value(USER_ID).(uuid.UUID)
|
|
||||||
|
|
||||||
imageId, err := uuid.Parse(stringImageId)
|
|
||||||
if err != nil {
|
|
||||||
w.WriteHeader(http.StatusForbidden)
|
|
||||||
fmt.Fprintf(w, "You cannot read this")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// if authorized := imageModel.IsUserAuthorized(r.Context(), imageId, userId); !authorized {
|
|
||||||
// w.WriteHeader(http.StatusForbidden)
|
|
||||||
// fmt.Fprintf(w, "You cannot read this")
|
|
||||||
// return
|
|
||||||
// }
|
|
||||||
|
|
||||||
image, err := imageModel.Get(r.Context(), imageId)
|
|
||||||
if err != nil {
|
|
||||||
log.Println(err)
|
|
||||||
w.WriteHeader(http.StatusNotFound)
|
|
||||||
fmt.Fprintf(w, "Could not get image")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: this could be part of the db table
|
|
||||||
extension := filepath.Ext(image.ImageName)
|
|
||||||
if len(extension) == 0 {
|
|
||||||
// Same hack
|
|
||||||
extension = "png"
|
|
||||||
}
|
|
||||||
extension = extension[1:]
|
|
||||||
|
|
||||||
w.Header().Add("Content-Type", "image/"+extension)
|
|
||||||
w.Write(image.Image)
|
|
||||||
})
|
|
||||||
|
|
||||||
r.Group(func(r chi.Router) {
|
|
||||||
r.Use(ProtectedRoute)
|
|
||||||
r.Use(func(next http.Handler) http.Handler {
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.Header().Add("Content-Type", "application/json")
|
|
||||||
|
|
||||||
next.ServeHTTP(w, r)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
r.Get("/image", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
userId := r.Context().Value(USER_ID).(uuid.UUID)
|
|
||||||
if err != nil {
|
|
||||||
w.WriteHeader(http.StatusForbidden)
|
|
||||||
fmt.Fprintf(w, "You cannot read this")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
images, err := userModel.GetUserImages(r.Context(), userId)
|
|
||||||
if err != nil {
|
|
||||||
log.Println(err)
|
|
||||||
w.WriteHeader(http.StatusNotFound)
|
|
||||||
fmt.Fprintf(w, "Something went wrong")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
processingImages, err := imageModel.GetProcessing(r.Context(), userId)
|
|
||||||
if err != nil {
|
|
||||||
log.Println(err)
|
|
||||||
w.WriteHeader(http.StatusNotFound)
|
|
||||||
fmt.Fprintf(w, "Something went wrong")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
listsWithImages, err := userModel.ListWithImages(r.Context(), userId)
|
|
||||||
if err != nil {
|
|
||||||
log.Println(err)
|
|
||||||
w.WriteHeader(http.StatusNotFound)
|
|
||||||
fmt.Fprintf(w, "Something went wrong")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
type ImagesReturn struct {
|
|
||||||
UserImages []models.UserImageWithImage
|
|
||||||
ProcessingImages []models.UserProcessingImage
|
|
||||||
Lists []models.ListsWithImages
|
|
||||||
}
|
|
||||||
|
|
||||||
imagesReturn := ImagesReturn{
|
|
||||||
UserImages: images,
|
|
||||||
ProcessingImages: processingImages,
|
|
||||||
Lists: listsWithImages,
|
|
||||||
}
|
|
||||||
|
|
||||||
jsonImages, err := json.Marshal(imagesReturn)
|
|
||||||
if err != nil {
|
|
||||||
log.Println(err)
|
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
|
||||||
fmt.Fprintf(w, "Could not create JSON response for this image")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Write(jsonImages)
|
|
||||||
})
|
|
||||||
|
|
||||||
r.Post("/image/{name}", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
imageName := r.PathValue("name")
|
|
||||||
userId := r.Context().Value(USER_ID).(uuid.UUID)
|
|
||||||
|
|
||||||
if len(imageName) == 0 {
|
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
|
||||||
fmt.Fprintf(w, "You need to provide a name in the path")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
contentType := r.Header.Get("Content-Type")
|
|
||||||
|
|
||||||
fmt.Printf("Content-Type: %s\n", contentType)
|
|
||||||
|
|
||||||
// TODO: length checks on body
|
|
||||||
// TODO: extract this shit out
|
|
||||||
image := make([]byte, 0)
|
|
||||||
switch contentType {
|
|
||||||
case "application/base64":
|
|
||||||
decoder := base64.NewDecoder(base64.StdEncoding, r.Body)
|
|
||||||
buf := &bytes.Buffer{}
|
|
||||||
|
|
||||||
decodedIamge, err := io.Copy(buf, decoder)
|
|
||||||
if err != nil {
|
|
||||||
log.Println(err)
|
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
|
||||||
fmt.Fprintf(w, "bruh, base64 aint decoding")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println(string(image))
|
|
||||||
fmt.Println(decodedIamge)
|
|
||||||
|
|
||||||
image = buf.Bytes()
|
|
||||||
case "application/oclet-stream", "image/png":
|
|
||||||
bodyData, err := io.ReadAll(r.Body)
|
|
||||||
if err != nil {
|
|
||||||
log.Println(err)
|
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
|
||||||
fmt.Fprintf(w, "bruh, binary aint binaring")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// TODO: check headers
|
|
||||||
|
|
||||||
image = bodyData
|
|
||||||
default:
|
|
||||||
log.Println("bad stuff?")
|
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
|
||||||
fmt.Fprintf(w, "Bruh, you need oclet stream or base64")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
log.Println("First case")
|
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
|
||||||
fmt.Fprintf(w, "Couldnt read the image from the request body")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
userImage, err := imageModel.Process(r.Context(), userId, model.Image{
|
|
||||||
Image: image,
|
|
||||||
ImageName: imageName,
|
|
||||||
Description: "",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
log.Println("Second case")
|
|
||||||
log.Println(err)
|
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
|
||||||
fmt.Fprintf(w, "Could not save image to DB")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
jsonUserImage, err := json.Marshal(userImage)
|
|
||||||
if err != nil {
|
|
||||||
log.Println("Third case")
|
|
||||||
log.Println(err)
|
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
|
||||||
fmt.Fprintf(w, "Could not create JSON response for this image")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
w.WriteHeader(http.StatusCreated)
|
|
||||||
|
|
||||||
fmt.Fprint(w, string(jsonUserImage))
|
|
||||||
w.Header().Add("Content-Type", "application/json")
|
|
||||||
})
|
|
||||||
|
|
||||||
})
|
|
||||||
|
|
||||||
r.Route("/notifications", func(r chi.Router) {
|
|
||||||
r.Use(GetUserIdFromUrl)
|
|
||||||
|
|
||||||
r.Get("/", CreateEventsHandler(¬ifier))
|
|
||||||
})
|
|
||||||
|
|
||||||
r.Post("/login", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
type LoginBody struct {
|
|
||||||
Email string `json:"email"`
|
|
||||||
}
|
|
||||||
|
|
||||||
loginBody := LoginBody{}
|
|
||||||
err := json.NewDecoder(r.Body).Decode(&loginBody)
|
|
||||||
if err != nil {
|
|
||||||
log.Println(err)
|
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
|
||||||
fmt.Fprintf(w, "Request body was not correct")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: validate it's an email
|
|
||||||
|
|
||||||
auth.CreateCode(loginBody.Email)
|
|
||||||
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
})
|
|
||||||
|
|
||||||
type CodeReturn struct {
|
|
||||||
Access string `json:"access"`
|
|
||||||
Refresh string `json:"refresh"`
|
|
||||||
}
|
|
||||||
|
|
||||||
r.Post("/code", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
type CodeBody struct {
|
|
||||||
Email string `json:"email"`
|
|
||||||
Code string `json:"code"`
|
|
||||||
}
|
|
||||||
|
|
||||||
codeBody := CodeBody{}
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&codeBody); err != nil {
|
|
||||||
log.Println(err)
|
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
|
||||||
fmt.Fprintf(w, "Request body was not correct")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := auth.UseCode(codeBody.Email, codeBody.Code); err != nil {
|
|
||||||
log.Println(err)
|
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
|
||||||
fmt.Fprintf(w, "email or code are incorrect")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if exists := userModel.DoesUserExist(r.Context(), codeBody.Email); !exists {
|
|
||||||
userModel.Save(r.Context(), model.Users{
|
|
||||||
Email: codeBody.Email,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
uuid, err := userModel.GetUserIdFromEmail(r.Context(), codeBody.Email)
|
|
||||||
if err != nil {
|
|
||||||
log.Println(err)
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
fmt.Fprintf(w, "Something went wrong.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
refresh := CreateRefreshToken(uuid)
|
|
||||||
access := CreateAccessToken(uuid)
|
|
||||||
|
|
||||||
codeReturn := CodeReturn{
|
|
||||||
Access: access,
|
|
||||||
Refresh: refresh,
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println(codeReturn)
|
|
||||||
|
|
||||||
json, err := json.Marshal(codeReturn)
|
|
||||||
if err != nil {
|
|
||||||
log.Println(err)
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
fmt.Fprintf(w, "Something went wrong.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
w.Header().Add("Content-Type", "application/json")
|
|
||||||
|
|
||||||
fmt.Fprint(w, string(json))
|
|
||||||
})
|
|
||||||
|
|
||||||
r.Get("/demo-login", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
uuid, err := userModel.GetUserIdFromEmail(r.Context(), "demo@email.com")
|
|
||||||
if err != nil {
|
|
||||||
log.Println(err)
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
fmt.Fprintf(w, "Something went wrong.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
refresh := CreateRefreshToken(uuid)
|
|
||||||
access := CreateAccessToken(uuid)
|
|
||||||
|
|
||||||
codeReturn := CodeReturn{
|
|
||||||
Access: access,
|
|
||||||
Refresh: refresh,
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println(codeReturn)
|
|
||||||
|
|
||||||
json, err := json.Marshal(codeReturn)
|
|
||||||
if err != nil {
|
|
||||||
log.Println(err)
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
fmt.Fprintf(w, "Something went wrong.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
w.Header().Add("Content-Type", "application/json")
|
|
||||||
|
|
||||||
fmt.Fprint(w, string(json))
|
|
||||||
})
|
|
||||||
|
|
||||||
logWriter := DatabaseWriter{
|
|
||||||
dbPool: db,
|
|
||||||
}
|
|
||||||
|
|
||||||
r.Route("/logs", createLogHandler(&logWriter))
|
|
||||||
|
|
||||||
log.Println("Listening and serving on port 3040.")
|
|
||||||
if err := http.ListenAndServe(":3040", r); err != nil {
|
|
||||||
log.Println(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -1,61 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"net/http"
|
|
||||||
)
|
|
||||||
|
|
||||||
func CorsMiddleware(next http.Handler) http.Handler {
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.Header().Add("Access-Control-Allow-Origin", "*")
|
|
||||||
w.Header().Add("Access-Control-Allow-Credentials", "*")
|
|
||||||
w.Header().Add("Access-Control-Allow-Headers", "*")
|
|
||||||
|
|
||||||
next.ServeHTTP(w, r)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const USER_ID = "UserID"
|
|
||||||
|
|
||||||
func ProtectedRoute(next http.Handler) http.Handler {
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
token := r.Header.Get("Authorization")
|
|
||||||
if len(token) < len("Bearer ") {
|
|
||||||
w.WriteHeader(http.StatusUnauthorized)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
userId, err := GetUserIdFromAccess(token[len("Bearer "):])
|
|
||||||
if err != nil {
|
|
||||||
w.WriteHeader(http.StatusUnauthorized)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
contextWithUserId := context.WithValue(r.Context(), USER_ID, userId)
|
|
||||||
|
|
||||||
newR := r.WithContext(contextWithUserId)
|
|
||||||
next.ServeHTTP(w, newR)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetUserIdFromUrl(next http.Handler) http.Handler {
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
token := r.URL.Query().Get("token")
|
|
||||||
|
|
||||||
if len(token) == 0 {
|
|
||||||
w.WriteHeader(http.StatusUnauthorized)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
userId, err := GetUserIdFromAccess(token)
|
|
||||||
if err != nil {
|
|
||||||
w.WriteHeader(http.StatusUnauthorized)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
contextWithUserId := context.WithValue(r.Context(), USER_ID, userId)
|
|
||||||
|
|
||||||
newR := r.WithContext(contextWithUserId)
|
|
||||||
next.ServeHTTP(w, newR)
|
|
||||||
})
|
|
||||||
}
|
|
29
backend/middleware/body.go
Normal file
29
backend/middleware/body.go
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func WithValidatedPost[K any](
|
||||||
|
fn func(request K, w http.ResponseWriter, r *http.Request),
|
||||||
|
) func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
request := new(K)
|
||||||
|
|
||||||
|
body, err := io.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = json.Unmarshal(body, request)
|
||||||
|
if err != nil {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fn(*request, w, r)
|
||||||
|
}
|
||||||
|
}
|
11
backend/middleware/json.go
Normal file
11
backend/middleware/json.go
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import "net/http"
|
||||||
|
|
||||||
|
func SetJson(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Add("Content-Type", "application/json")
|
||||||
|
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
@ -1,4 +1,4 @@
|
|||||||
package main
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
116
backend/middleware/middleware.go
Normal file
116
backend/middleware/middleware.go
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/log"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
func CorsMiddleware(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Add("Access-Control-Allow-Origin", "*")
|
||||||
|
w.Header().Add("Access-Control-Allow-Headers", "*")
|
||||||
|
|
||||||
|
// Access-Control-Allow-Methods is often needed for preflight OPTIONS requests
|
||||||
|
w.Header().Add("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
||||||
|
|
||||||
|
// The client makes an OPTIONS preflight request before a complex request.
|
||||||
|
// We must handle this and respond with the appropriate headers.
|
||||||
|
if r.Method == "OPTIONS" {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const USER_ID = "UserID"
|
||||||
|
|
||||||
|
func GetUserID(ctx context.Context, logger *log.Logger, w http.ResponseWriter) (uuid.UUID, error) {
|
||||||
|
userId := ctx.Value(USER_ID)
|
||||||
|
|
||||||
|
if userId == nil {
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
logger.Warn("UserID not present in request")
|
||||||
|
return uuid.Nil, errors.New("context does not contain a user id")
|
||||||
|
}
|
||||||
|
|
||||||
|
userIdUuid, ok := userId.(uuid.UUID)
|
||||||
|
if !ok {
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
logger.Warn("UserID not of correct type")
|
||||||
|
return uuid.Nil, fmt.Errorf("context user id is not of type uuid, got: %t", userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
return userIdUuid, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ProtectedRoute(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
token := r.Header.Get("Authorization")
|
||||||
|
if len(token) < len("Bearer ") {
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userId, err := GetUserIdFromAccess(token[len("Bearer "):])
|
||||||
|
if err != nil {
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
contextWithUserId := context.WithValue(r.Context(), USER_ID, userId)
|
||||||
|
|
||||||
|
newR := r.WithContext(contextWithUserId)
|
||||||
|
next.ServeHTTP(w, newR)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetUserIdFromUrl(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
token := r.URL.Query().Get("token")
|
||||||
|
|
||||||
|
if len(token) == 0 {
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userId, err := GetUserIdFromAccess(token)
|
||||||
|
if err != nil {
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
contextWithUserId := context.WithValue(r.Context(), USER_ID, userId)
|
||||||
|
|
||||||
|
newR := r.WithContext(contextWithUserId)
|
||||||
|
next.ServeHTTP(w, newR)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetPathParamID(logger *log.Logger, param string, w http.ResponseWriter, r *http.Request) (uuid.UUID, error) {
|
||||||
|
pathParam := r.PathValue(param)
|
||||||
|
if len(pathParam) == 0 {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
|
||||||
|
err := fmt.Errorf("%s was not present", param)
|
||||||
|
logger.Warn(err)
|
||||||
|
return uuid.Nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
uuidParam, err := uuid.Parse(pathParam)
|
||||||
|
if err != nil {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
|
||||||
|
err := fmt.Errorf("could not parse param: %w", err)
|
||||||
|
logger.Warn(err)
|
||||||
|
return uuid.Nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return uuidParam, nil
|
||||||
|
}
|
48
backend/middleware/util.go
Normal file
48
backend/middleware/util.go
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
func WriteJsonOrError[K any](logger *log.Logger, object K, w http.ResponseWriter) {
|
||||||
|
jsonObject, err := json.Marshal(object)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("could not marshal json object", "err", err)
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Write(jsonObject)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ErrorObject struct {
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeError(logger *log.Logger, error string, w http.ResponseWriter, code int) {
|
||||||
|
e := ErrorObject{
|
||||||
|
error,
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonObject, err := json.Marshal(e)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("could not marshal json object", "err", err)
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Write(jsonObject)
|
||||||
|
w.WriteHeader(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func WriteErrorBadRequest(logger *log.Logger, error string, w http.ResponseWriter) {
|
||||||
|
writeError(logger, error, w, http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
func WriteErrorInternal(logger *log.Logger, error string, w http.ResponseWriter) {
|
||||||
|
writeError(logger, error, w, http.StatusInternalServerError)
|
||||||
|
}
|
@ -38,7 +38,7 @@ type UserProcessingImage struct {
|
|||||||
func (m ImageModel) Process(ctx context.Context, userId uuid.UUID, image model.Image) (model.UserImagesToProcess, error) {
|
func (m ImageModel) Process(ctx context.Context, userId uuid.UUID, image model.Image) (model.UserImagesToProcess, error) {
|
||||||
tx, err := m.dbPool.BeginTx(ctx, nil)
|
tx, err := m.dbPool.BeginTx(ctx, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return model.UserImagesToProcess{}, fmt.Errorf("Failed to begin transaction", err)
|
return model.UserImagesToProcess{}, fmt.Errorf("Failed to begin transaction: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
insertImageStmt := Image.
|
insertImageStmt := Image.
|
||||||
@ -49,7 +49,7 @@ func (m ImageModel) Process(ctx context.Context, userId uuid.UUID, image model.I
|
|||||||
insertedImage := model.Image{}
|
insertedImage := model.Image{}
|
||||||
err = insertImageStmt.QueryContext(ctx, tx, &insertedImage)
|
err = insertImageStmt.QueryContext(ctx, tx, &insertedImage)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return model.UserImagesToProcess{}, fmt.Errorf("Could not insert/query new image. SQL %s.", insertImageStmt.DebugSql(), err)
|
return model.UserImagesToProcess{}, fmt.Errorf("Could not insert/query new image. SQL %s: %w", insertImageStmt.DebugSql(), err)
|
||||||
}
|
}
|
||||||
|
|
||||||
stmt := UserImagesToProcess.
|
stmt := UserImagesToProcess.
|
||||||
@ -60,7 +60,7 @@ func (m ImageModel) Process(ctx context.Context, userId uuid.UUID, image model.I
|
|||||||
userImage := model.UserImagesToProcess{}
|
userImage := model.UserImagesToProcess{}
|
||||||
err = stmt.QueryContext(ctx, tx, &userImage)
|
err = stmt.QueryContext(ctx, tx, &userImage)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return model.UserImagesToProcess{}, fmt.Errorf("Could not insert user_image", err)
|
return model.UserImagesToProcess{}, fmt.Errorf("Could not insert user_image: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = tx.Commit()
|
err = tx.Commit()
|
||||||
|
@ -26,6 +26,115 @@ type ListWithItems struct {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ImageWithSchema struct {
|
||||||
|
model.ImageLists
|
||||||
|
|
||||||
|
Items []model.ImageSchemaItems
|
||||||
|
}
|
||||||
|
|
||||||
|
type IDValue struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// SELECT for lists
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
func (m ListModel) List(ctx context.Context, userId uuid.UUID) ([]ListWithItems, error) {
|
||||||
|
getListsWithItems := SELECT(
|
||||||
|
Lists.AllColumns,
|
||||||
|
Schemas.AllColumns,
|
||||||
|
SchemaItems.AllColumns,
|
||||||
|
).
|
||||||
|
FROM(
|
||||||
|
Lists.
|
||||||
|
INNER_JOIN(Schemas, Schemas.ListID.EQ(Lists.ID)).
|
||||||
|
INNER_JOIN(SchemaItems, SchemaItems.SchemaID.EQ(Schemas.ID)),
|
||||||
|
).
|
||||||
|
WHERE(Lists.UserID.EQ(UUID(userId)))
|
||||||
|
|
||||||
|
lists := []ListWithItems{}
|
||||||
|
err := getListsWithItems.QueryContext(ctx, m.dbPool, &lists)
|
||||||
|
|
||||||
|
return lists, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m ListModel) ListItems(ctx context.Context, listID uuid.UUID) ([]ImageWithSchema, error) {
|
||||||
|
getListItems := SELECT(
|
||||||
|
ImageLists.AllColumns,
|
||||||
|
ImageSchemaItems.AllColumns,
|
||||||
|
).
|
||||||
|
FROM(
|
||||||
|
ImageLists.
|
||||||
|
INNER_JOIN(ImageSchemaItems, ImageSchemaItems.ImageID.EQ(ImageLists.ImageID)),
|
||||||
|
).
|
||||||
|
WHERE(ImageLists.ListID.EQ(UUID(listID)))
|
||||||
|
|
||||||
|
listItems := make([]ImageWithSchema, 0)
|
||||||
|
err := getListItems.QueryContext(ctx, m.dbPool, &listItems)
|
||||||
|
|
||||||
|
return listItems, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// SELECT for specific items
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
func (m ListModel) GetProcessing(ctx context.Context, processingListID uuid.UUID) (model.ProcessingLists, error) {
|
||||||
|
getProcessingListStmt := ProcessingLists.
|
||||||
|
SELECT(ProcessingLists.AllColumns).
|
||||||
|
WHERE(ProcessingLists.ID.EQ(UUID(processingListID)))
|
||||||
|
|
||||||
|
list := model.ProcessingLists{}
|
||||||
|
err := getProcessingListStmt.QueryContext(ctx, m.dbPool, &list)
|
||||||
|
|
||||||
|
return list, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m ListModel) GetToProcess(ctx context.Context, listID uuid.UUID) (model.ProcessingLists, error) {
|
||||||
|
getToProcessStmt := ProcessingLists.
|
||||||
|
SELECT(ProcessingLists.AllColumns).
|
||||||
|
WHERE(ProcessingLists.ID.EQ(UUID(listID)))
|
||||||
|
|
||||||
|
stack := []model.ProcessingLists{}
|
||||||
|
err := getToProcessStmt.QueryContext(ctx, m.dbPool, &stack)
|
||||||
|
|
||||||
|
if len(stack) != 1 {
|
||||||
|
return model.ProcessingLists{}, fmt.Errorf("Expected 1, got %d\n", len(stack))
|
||||||
|
}
|
||||||
|
|
||||||
|
return stack[0], err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// UPDATE
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
func (m ListModel) StartProcessing(ctx context.Context, processingListID uuid.UUID) error {
|
||||||
|
startProcessingStmt := ProcessingLists.
|
||||||
|
UPDATE(ProcessingLists.Status).
|
||||||
|
SET(model.Progress_InProgress).
|
||||||
|
WHERE(ProcessingLists.ID.EQ(UUID(processingListID)))
|
||||||
|
|
||||||
|
_, err := startProcessingStmt.ExecContext(ctx, m.dbPool)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m ListModel) EndProcessing(ctx context.Context, processingListID uuid.UUID) error {
|
||||||
|
startProcessingStmt := ProcessingLists.
|
||||||
|
UPDATE(ProcessingLists.Status).
|
||||||
|
SET(model.Progress_Complete).
|
||||||
|
WHERE(ProcessingLists.ID.EQ(UUID(processingListID)))
|
||||||
|
|
||||||
|
_, err := startProcessingStmt.ExecContext(ctx, m.dbPool)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// INSERT methods
|
||||||
|
// ========================================
|
||||||
|
|
||||||
func (m ListModel) Save(ctx context.Context, userId uuid.UUID, name string, description string, schemaItems []model.SchemaItems) (ListWithItems, error) {
|
func (m ListModel) Save(ctx context.Context, userId uuid.UUID, name string, description string, schemaItems []model.SchemaItems) (ListWithItems, error) {
|
||||||
tx, err := m.dbPool.BeginTx(ctx, nil)
|
tx, err := m.dbPool.BeginTx(ctx, nil)
|
||||||
|
|
||||||
@ -86,30 +195,6 @@ func (m ListModel) Save(ctx context.Context, userId uuid.UUID, name string, desc
|
|||||||
return listWithItems, err
|
return listWithItems, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m ListModel) List(ctx context.Context, userId uuid.UUID) ([]ListWithItems, error) {
|
|
||||||
getListsWithItems := SELECT(
|
|
||||||
Lists.AllColumns,
|
|
||||||
Schemas.AllColumns,
|
|
||||||
SchemaItems.AllColumns,
|
|
||||||
).
|
|
||||||
FROM(
|
|
||||||
Lists.
|
|
||||||
INNER_JOIN(Schemas, Schemas.ListID.EQ(Lists.ID)).
|
|
||||||
INNER_JOIN(SchemaItems, SchemaItems.SchemaID.EQ(Schemas.ID)),
|
|
||||||
).
|
|
||||||
WHERE(Lists.UserID.EQ(UUID(userId)))
|
|
||||||
|
|
||||||
lists := []ListWithItems{}
|
|
||||||
err := getListsWithItems.QueryContext(ctx, m.dbPool, &lists)
|
|
||||||
|
|
||||||
return lists, err
|
|
||||||
}
|
|
||||||
|
|
||||||
type IDValue struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Value string `json:"value"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m ListModel) SaveInto(ctx context.Context, listId uuid.UUID, imageId uuid.UUID, schemaValues []IDValue) error {
|
func (m ListModel) SaveInto(ctx context.Context, listId uuid.UUID, imageId uuid.UUID, schemaValues []IDValue) error {
|
||||||
imageSchemaItems := make([]model.ImageSchemaItems, len(schemaValues))
|
imageSchemaItems := make([]model.ImageSchemaItems, len(schemaValues))
|
||||||
|
|
||||||
@ -152,6 +237,74 @@ func (m ListModel) SaveInto(ctx context.Context, listId uuid.UUID, imageId uuid.
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m ListModel) SaveProcessing(ctx context.Context, userID uuid.UUID, title string, fields string) error {
|
||||||
|
insertListToProcess := ProcessingLists.
|
||||||
|
INSERT(ProcessingLists.UserID, ProcessingLists.Title, ProcessingLists.Fields).
|
||||||
|
VALUES(userID, title, fields)
|
||||||
|
|
||||||
|
_, err := insertListToProcess.ExecContext(ctx, m.dbPool)
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// DELETE methods
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
func (m ListModel) Delete(ctx context.Context, listID uuid.UUID, userID uuid.UUID) error {
|
||||||
|
// First verify the list belongs to the user
|
||||||
|
checkOwnershipStmt := Lists.
|
||||||
|
SELECT(Lists.ID).
|
||||||
|
WHERE(Lists.ID.EQ(UUID(listID)).AND(Lists.UserID.EQ(UUID(userID))))
|
||||||
|
|
||||||
|
var existingList model.Lists
|
||||||
|
err := checkOwnershipStmt.QueryContext(ctx, m.dbPool, &existingList)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("could not verify list ownership: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start a transaction to ensure all deletions happen atomically
|
||||||
|
tx, err := m.dbPool.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("could not start transaction: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
// Delete in reverse order of dependencies:
|
||||||
|
// 1. Delete schema items first
|
||||||
|
deleteSchemaItemsStmt := SchemaItems.DELETE().
|
||||||
|
WHERE(SchemaItems.SchemaID.IN(
|
||||||
|
Schemas.SELECT(Schemas.ID).
|
||||||
|
WHERE(Schemas.ListID.EQ(UUID(listID))),
|
||||||
|
))
|
||||||
|
_, err = deleteSchemaItemsStmt.ExecContext(ctx, tx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("could not delete schema items: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Delete schemas
|
||||||
|
deleteSchemasStmt := Schemas.DELETE().WHERE(Schemas.ListID.EQ(UUID(listID)))
|
||||||
|
_, err = deleteSchemasStmt.ExecContext(ctx, tx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("could not delete schemas: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Delete the list itself
|
||||||
|
deleteListStmt := Lists.DELETE().WHERE(Lists.ID.EQ(UUID(listID)))
|
||||||
|
_, err = deleteListStmt.ExecContext(ctx, tx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("could not delete list: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Commit the transaction
|
||||||
|
err = tx.Commit()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("could not commit transaction: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func NewListModel(db *sql.DB) ListModel {
|
func NewListModel(db *sql.DB) ListModel {
|
||||||
return ListModel{dbPool: db}
|
return ListModel{dbPool: db}
|
||||||
}
|
}
|
||||||
|
@ -51,7 +51,10 @@ func (m UserModel) Save(ctx context.Context, user model.Users) (model.Users, err
|
|||||||
type UserImageWithImage struct {
|
type UserImageWithImage struct {
|
||||||
model.UserImages
|
model.UserImages
|
||||||
|
|
||||||
Image model.Image
|
Image struct {
|
||||||
|
model.Image
|
||||||
|
ImageLists []model.ImageLists
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m UserModel) GetUserImages(ctx context.Context, userId uuid.UUID) ([]UserImageWithImage, error) {
|
func (m UserModel) GetUserImages(ctx context.Context, userId uuid.UUID) ([]UserImageWithImage, error) {
|
||||||
@ -60,8 +63,13 @@ func (m UserModel) GetUserImages(ctx context.Context, userId uuid.UUID) ([]UserI
|
|||||||
Image.ID,
|
Image.ID,
|
||||||
Image.ImageName,
|
Image.ImageName,
|
||||||
Image.Description,
|
Image.Description,
|
||||||
|
ImageLists.AllColumns,
|
||||||
|
).
|
||||||
|
FROM(
|
||||||
|
UserImages.
|
||||||
|
INNER_JOIN(Image, Image.ID.EQ(UserImages.ImageID)).
|
||||||
|
INNER_JOIN(ImageLists, ImageLists.ImageID.EQ(UserImages.ImageID)),
|
||||||
).
|
).
|
||||||
FROM(UserImages.INNER_JOIN(Image, Image.ID.EQ(UserImages.ImageID))).
|
|
||||||
WHERE(UserImages.UserID.EQ(UUID(userId)))
|
WHERE(UserImages.UserID.EQ(UUID(userId)))
|
||||||
|
|
||||||
userImages := []UserImageWithImage{}
|
userImages := []UserImageWithImage{}
|
||||||
@ -96,10 +104,10 @@ func (m UserModel) ListWithImages(ctx context.Context, userId uuid.UUID) ([]List
|
|||||||
).
|
).
|
||||||
FROM(
|
FROM(
|
||||||
Lists.
|
Lists.
|
||||||
INNER_JOIN(ImageLists, ImageLists.ListID.EQ(Lists.ID)).
|
|
||||||
INNER_JOIN(Schemas, Schemas.ListID.EQ(Lists.ID)).
|
INNER_JOIN(Schemas, Schemas.ListID.EQ(Lists.ID)).
|
||||||
INNER_JOIN(SchemaItems, SchemaItems.SchemaID.EQ(Schemas.ID)).
|
INNER_JOIN(SchemaItems, SchemaItems.SchemaID.EQ(Schemas.ID)).
|
||||||
INNER_JOIN(ImageSchemaItems, ImageSchemaItems.ImageID.EQ(ImageLists.ImageID)),
|
LEFT_JOIN(ImageLists, ImageLists.ListID.EQ(Lists.ID)).
|
||||||
|
LEFT_JOIN(ImageSchemaItems, ImageSchemaItems.ImageID.EQ(ImageLists.ImageID)),
|
||||||
).
|
).
|
||||||
WHERE(Lists.UserID.EQ(UUID(userId)))
|
WHERE(Lists.UserID.EQ(UUID(userId)))
|
||||||
|
|
||||||
|
71
backend/router.go
Normal file
71
backend/router.go
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"os"
|
||||||
|
"screenmark/screenmark/agents/client"
|
||||||
|
"screenmark/screenmark/auth"
|
||||||
|
"screenmark/screenmark/images"
|
||||||
|
"screenmark/screenmark/models"
|
||||||
|
"screenmark/screenmark/stacks"
|
||||||
|
|
||||||
|
ourmiddleware "screenmark/screenmark/middleware"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TestAiClient struct {
|
||||||
|
ImageInfo client.ImageMessageContent
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client TestAiClient) GetImageInfo(imageName string, imageData []byte) (client.ImageMessageContent, error) {
|
||||||
|
return client.ImageInfo, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupRouter(db *sql.DB) chi.Router {
|
||||||
|
imageModel := models.NewImageModel(db)
|
||||||
|
stackModel := models.NewListModel(db)
|
||||||
|
|
||||||
|
stackHandler := stacks.CreateStackHandler(db)
|
||||||
|
authHandler := auth.CreateAuthHandler(db)
|
||||||
|
imageHandler := images.CreateImageHandler(db)
|
||||||
|
|
||||||
|
notifier := NewNotifier[Notification](10)
|
||||||
|
|
||||||
|
// Only start event listeners if not in test environment
|
||||||
|
if os.Getenv("GO_TEST_ENVIRONMENT") != "true" {
|
||||||
|
|
||||||
|
// TODO: should extract these into a notification manager
|
||||||
|
// And actually make them the same code.
|
||||||
|
// The events are basically the same.
|
||||||
|
|
||||||
|
go ListenNewImageEvents(db)
|
||||||
|
go ListenProcessingImageStatus(db, imageModel, ¬ifier)
|
||||||
|
go ListenNewStackEvents(db)
|
||||||
|
go ListenProcessingStackStatus(db, stackModel, ¬ifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
r := chi.NewRouter()
|
||||||
|
|
||||||
|
r.Use(middleware.Logger)
|
||||||
|
r.Use(ourmiddleware.CorsMiddleware)
|
||||||
|
|
||||||
|
r.Route("/stacks", stackHandler.CreateRoutes)
|
||||||
|
r.Route("/auth", authHandler.CreateRoutes)
|
||||||
|
r.Route("/images", imageHandler.CreateRoutes)
|
||||||
|
|
||||||
|
r.Route("/notifications", func(r chi.Router) {
|
||||||
|
r.Use(ourmiddleware.GetUserIdFromUrl)
|
||||||
|
|
||||||
|
r.Get("/", CreateEventsHandler(¬ifier))
|
||||||
|
})
|
||||||
|
|
||||||
|
logWriter := DatabaseWriter{
|
||||||
|
dbPool: db,
|
||||||
|
}
|
||||||
|
|
||||||
|
r.Route("/logs", createLogHandler(&logWriter))
|
||||||
|
|
||||||
|
return r
|
||||||
|
}
|
@ -52,6 +52,18 @@ CREATE TABLE haystack.lists (
|
|||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT now()
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT now()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE haystack.processing_lists (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES haystack.users (id),
|
||||||
|
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
fields TEXT NOT NULL,
|
||||||
|
|
||||||
|
status haystack.progress NOT NULL DEFAULT 'not-started',
|
||||||
|
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE haystack.image_lists (
|
CREATE TABLE haystack.image_lists (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
@ -104,6 +116,22 @@ PERFORM pg_notify('new_processing_image_status', NEW.id::text || NEW.status::tex
|
|||||||
END
|
END
|
||||||
$$ LANGUAGE plpgsql;
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION notify_new_stacks()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
PERFORM pg_notify('new_stack', NEW.id::text);
|
||||||
|
RETURN NEW;
|
||||||
|
END
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION notify_new_processing_stack_status()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
PERFORM pg_notify('new_processing_stack_status', NEW.id::text || NEW.status::text);
|
||||||
|
RETURN NEW;
|
||||||
|
END
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
/* -----| Triggers |----- */
|
/* -----| Triggers |----- */
|
||||||
|
|
||||||
CREATE OR REPLACE TRIGGER on_new_image AFTER INSERT
|
CREATE OR REPLACE TRIGGER on_new_image AFTER INSERT
|
||||||
@ -117,4 +145,15 @@ ON haystack.user_images_to_process
|
|||||||
FOR EACH ROW
|
FOR EACH ROW
|
||||||
EXECUTE PROCEDURE notify_new_processing_image_status();
|
EXECUTE PROCEDURE notify_new_processing_image_status();
|
||||||
|
|
||||||
|
CREATE OR REPLACE TRIGGER on_new_image AFTER INSERT
|
||||||
|
ON haystack.processing_lists
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE PROCEDURE notify_new_stacks();
|
||||||
|
|
||||||
|
CREATE OR REPLACE TRIGGER on_update_stack_progress
|
||||||
|
AFTER UPDATE OF status
|
||||||
|
ON haystack.processing_lists
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE PROCEDURE notify_new_processing_stack_status();
|
||||||
|
|
||||||
/* -----| Test Data |----- */
|
/* -----| Test Data |----- */
|
||||||
|
161
backend/stacks/handler.go
Normal file
161
backend/stacks/handler.go
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
package stacks
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
. "screenmark/screenmark/.gen/haystack/haystack/model"
|
||||||
|
"screenmark/screenmark/middleware"
|
||||||
|
"screenmark/screenmark/models"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/log"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
type StackHandler struct {
|
||||||
|
logger *log.Logger
|
||||||
|
stackModel models.ListModel
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *StackHandler) getAllStacks(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
userID, err := middleware.GetUserID(ctx, h.logger, w)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
lists, err := h.stackModel.List(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Warn("could not get stacks", "err", err)
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
middleware.WriteJsonOrError(h.logger, lists, w)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *StackHandler) getStackItems(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
_, err := middleware.GetUserID(ctx, h.logger, w)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
listID, err := middleware.GetPathParamID(h.logger, "listID", w, r)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: must check for permission here.
|
||||||
|
|
||||||
|
lists, err := h.stackModel.ListItems(ctx, listID)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Warn("could not get list items", "err", err)
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
middleware.WriteJsonOrError(h.logger, lists, w)
|
||||||
|
}
|
||||||
|
|
||||||
|
type EditStack struct {
|
||||||
|
Hello string `json:"hello"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *StackHandler) editStack(req EditStack, w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNotImplemented)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *StackHandler) deleteStack(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
userID, err := middleware.GetUserID(ctx, h.logger, w)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
listID, err := middleware.GetPathParamID(h.logger, "listID", w, r)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = h.stackModel.Delete(ctx, listID, userID)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Warn("could not delete stack", "err", err)
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateStackBody struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
|
||||||
|
// We want a regular string because AI will take care of creating these for us.
|
||||||
|
Fields string `json:"fields"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *StackHandler) createStack(body CreateStackBody, w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
userID, err := middleware.GetUserID(ctx, h.logger, w)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert fields string to basic schema items
|
||||||
|
// For now, create a simple schema item for each field
|
||||||
|
var schemaItems []SchemaItems
|
||||||
|
if body.Fields != "" {
|
||||||
|
fields := strings.Split(body.Fields, ",")
|
||||||
|
for i, field := range fields {
|
||||||
|
field = strings.TrimSpace(field)
|
||||||
|
if field != "" {
|
||||||
|
schemaItems = append(schemaItems, SchemaItems{
|
||||||
|
Item: field,
|
||||||
|
Value: "",
|
||||||
|
Description: fmt.Sprintf("Field %d: %s", i+1, field),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use empty description for now since the API doesn't provide one
|
||||||
|
_, err = h.stackModel.Save(ctx, userID, body.Title, "", schemaItems)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Warn("could not save stack", "err", err)
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *StackHandler) CreateRoutes(r chi.Router) {
|
||||||
|
h.logger.Info("Mounting stack router")
|
||||||
|
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Use(middleware.ProtectedRoute)
|
||||||
|
r.Use(middleware.SetJson)
|
||||||
|
|
||||||
|
r.Get("/", h.getAllStacks)
|
||||||
|
r.Get("/{listID}", h.getStackItems)
|
||||||
|
|
||||||
|
r.Post("/", middleware.WithValidatedPost(h.createStack))
|
||||||
|
r.Patch("/{listID}", middleware.WithValidatedPost(h.editStack))
|
||||||
|
r.Delete("/{listID}", h.deleteStack)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func CreateStackHandler(db *sql.DB) StackHandler {
|
||||||
|
stackModel := models.NewListModel(db)
|
||||||
|
logger := log.New(os.Stdout).WithPrefix("Stacks")
|
||||||
|
|
||||||
|
return StackHandler{
|
||||||
|
logger,
|
||||||
|
stackModel,
|
||||||
|
}
|
||||||
|
}
|
@ -7,7 +7,18 @@ export const ImageComponent: Component<{ ID: string }> = (props) => {
|
|||||||
<A href={`/image/${props.ID}`} class="w-full flex justify-center h-[300px]">
|
<A href={`/image/${props.ID}`} class="w-full flex justify-center h-[300px]">
|
||||||
<img
|
<img
|
||||||
class="flex w-full object-cover rounded-xl"
|
class="flex w-full object-cover rounded-xl"
|
||||||
src={`${base}/image/${props.ID}`}
|
src={`${base}/images/${props.ID}`}
|
||||||
|
/>
|
||||||
|
</A>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ImageComponentFullHeight: Component<{ ID: string }> = (props) => {
|
||||||
|
return (
|
||||||
|
<A href={`/image/${props.ID}`} class="w-full flex justify-center">
|
||||||
|
<img
|
||||||
|
class="flex w-full object-cover rounded-xl"
|
||||||
|
src={`${base}/images/${props.ID}`}
|
||||||
/>
|
/>
|
||||||
</A>
|
</A>
|
||||||
);
|
);
|
||||||
|
35
frontend/src/components/list-card/index.tsx
Normal file
35
frontend/src/components/list-card/index.tsx
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import { List } from "@network/index";
|
||||||
|
import { Component } from "solid-js";
|
||||||
|
import fastHashCode from "../../utils/hash";
|
||||||
|
import { A } from "@solidjs/router";
|
||||||
|
|
||||||
|
const colors = [
|
||||||
|
"bg-emerald-50",
|
||||||
|
"bg-lime-50",
|
||||||
|
|
||||||
|
"bg-indigo-50",
|
||||||
|
"bg-sky-50",
|
||||||
|
|
||||||
|
"bg-amber-50",
|
||||||
|
"bg-teal-50",
|
||||||
|
|
||||||
|
"bg-fuchsia-50",
|
||||||
|
"bg-pink-50",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const ListCard: Component<{ list: List }> = (props) => {
|
||||||
|
return (
|
||||||
|
<A
|
||||||
|
href={`/list/${props.list.ID}`}
|
||||||
|
class={
|
||||||
|
"flex flex-col p-4 border border-neutral-200 rounded-lg " +
|
||||||
|
colors[
|
||||||
|
fastHashCode(props.list.Name, { forcePositive: true }) % colors.length
|
||||||
|
]
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<p class="text-xl font-bold">{props.list.Name}</p>
|
||||||
|
<p class="text-lg">{props.list.Images.length}</p>
|
||||||
|
</A>
|
||||||
|
);
|
||||||
|
};
|
@ -8,7 +8,8 @@ export const ProcessingImages: Component = () => {
|
|||||||
const notifications = useNotifications();
|
const notifications = useNotifications();
|
||||||
|
|
||||||
const processingNumber = () =>
|
const processingNumber = () =>
|
||||||
Object.keys(notifications.state.ProcessingImages).length;
|
Object.keys(notifications.state.ProcessingImages).length +
|
||||||
|
Object.keys(notifications.state.ProcessingLists).length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover sameWidth gutter={4}>
|
<Popover sameWidth gutter={4}>
|
||||||
@ -16,7 +17,7 @@ export const ProcessingImages: Component = () => {
|
|||||||
<Show when={processingNumber() > 0}>
|
<Show when={processingNumber() > 0}>
|
||||||
<p class="text-md">
|
<p class="text-md">
|
||||||
Processing {processingNumber()}{" "}
|
Processing {processingNumber()}{" "}
|
||||||
{processingNumber() === 1 ? "image" : "images"}
|
{processingNumber() === 1 ? "item" : "items"}
|
||||||
...
|
...
|
||||||
</p>
|
</p>
|
||||||
</Show>
|
</Show>
|
||||||
@ -30,10 +31,8 @@ export const ProcessingImages: Component = () => {
|
|||||||
<Popover.Portal>
|
<Popover.Portal>
|
||||||
<Popover.Content class="shadow-2xl flex flex-col gap-2 bg-white rounded-xl p-2">
|
<Popover.Content class="shadow-2xl flex flex-col gap-2 bg-white rounded-xl p-2">
|
||||||
<Show
|
<Show
|
||||||
when={
|
when={processingNumber() > 0}
|
||||||
Object.entries(notifications.state.ProcessingImages).length > 0
|
fallback={<p>No items to process</p>}
|
||||||
}
|
|
||||||
fallback={<p>No images to process</p>}
|
|
||||||
>
|
>
|
||||||
<For each={Object.entries(notifications.state.ProcessingImages)}>
|
<For each={Object.entries(notifications.state.ProcessingImages)}>
|
||||||
{([id, _image]) => (
|
{([id, _image]) => (
|
||||||
@ -43,7 +42,7 @@ export const ProcessingImages: Component = () => {
|
|||||||
<img
|
<img
|
||||||
class="w-16 h-16 aspect-square rounded"
|
class="w-16 h-16 aspect-square rounded"
|
||||||
alt="processing"
|
alt="processing"
|
||||||
src={`${base}/image/${id}`}
|
src={`${base}/images/${id}`}
|
||||||
/>
|
/>
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1">
|
||||||
<p class="text-slate-100">{image().ImageName}</p>
|
<p class="text-slate-100">{image().ImageName}</p>
|
||||||
@ -57,6 +56,24 @@ export const ProcessingImages: Component = () => {
|
|||||||
</Show>
|
</Show>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
|
|
||||||
|
<For each={Object.entries(notifications.state.ProcessingLists)}>
|
||||||
|
{([, _list]) => (
|
||||||
|
<Show when={_list}>
|
||||||
|
{(list) => (
|
||||||
|
<div class="flex gap-2 w-full justify-center">
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<p class="text-slate-900">New Stack: {list().Name}</p>
|
||||||
|
</div>
|
||||||
|
<LoadingCircle
|
||||||
|
status="loading"
|
||||||
|
class="ml-auto self-center"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
</Show>
|
</Show>
|
||||||
</Popover.Content>
|
</Popover.Content>
|
||||||
</Popover.Portal>
|
</Popover.Portal>
|
||||||
|
@ -10,18 +10,27 @@ import {
|
|||||||
useContext,
|
useContext,
|
||||||
} from "solid-js";
|
} from "solid-js";
|
||||||
import { base } from "@network/index";
|
import { base } from "@network/index";
|
||||||
import { processingImagesValidator } from "@network/notifications";
|
import {
|
||||||
|
notificationValidator,
|
||||||
|
processingImagesValidator,
|
||||||
|
processingListValidator,
|
||||||
|
} from "@network/notifications";
|
||||||
|
|
||||||
type NotificationState = {
|
type NotificationState = {
|
||||||
ProcessingImages: Record<
|
ProcessingImages: Record<
|
||||||
string,
|
string,
|
||||||
InferOutput<typeof processingImagesValidator> | undefined
|
InferOutput<typeof processingImagesValidator> | undefined
|
||||||
>;
|
>;
|
||||||
|
ProcessingLists: Record<
|
||||||
|
string,
|
||||||
|
InferOutput<typeof processingListValidator> | undefined
|
||||||
|
>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Notifications = (onCompleteImage: () => void) => {
|
export const Notifications = (onCompleteImage: () => void) => {
|
||||||
const [state, setState] = createStore<NotificationState>({
|
const [state, setState] = createStore<NotificationState>({
|
||||||
ProcessingImages: {},
|
ProcessingImages: {},
|
||||||
|
ProcessingLists: {},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { processingImages } = useSearchImageContext();
|
const { processingImages } = useSearchImageContext();
|
||||||
@ -45,21 +54,32 @@ export const Notifications = (onCompleteImage: () => void) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const processingImage = safeParse(processingImagesValidator, jsonData);
|
const notification = safeParse(notificationValidator, jsonData);
|
||||||
if (!processingImage.success) {
|
if (!notification.success) {
|
||||||
console.error("Processing image could not be parsed.", e.data);
|
console.error("Processing image could not be parsed.", e.data);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("SSE: ", processingImage);
|
console.log("SSE: ", notification);
|
||||||
|
|
||||||
const { ImageID, Status } = processingImage.output;
|
if (notification.output.Type === "image") {
|
||||||
|
const { ImageID, Status } = notification.output;
|
||||||
|
|
||||||
if (Status === "complete") {
|
if (Status === "complete") {
|
||||||
setState("ProcessingImages", ImageID, undefined);
|
setState("ProcessingImages", ImageID, undefined);
|
||||||
onCompleteImage();
|
onCompleteImage();
|
||||||
} else {
|
} else {
|
||||||
setState("ProcessingImages", ImageID, processingImage.output);
|
setState("ProcessingImages", ImageID, notification.output);
|
||||||
|
}
|
||||||
|
} else if (notification.output.Type === "list") {
|
||||||
|
const { ListID, Status } = notification.output;
|
||||||
|
|
||||||
|
if (Status === "complete") {
|
||||||
|
setState("ProcessingLists", ListID, undefined);
|
||||||
|
onCompleteImage();
|
||||||
|
} else {
|
||||||
|
setState("ProcessingLists", ListID, notification.output);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -83,6 +103,7 @@ export const Notifications = (onCompleteImage: () => void) => {
|
|||||||
images.map((i) => [
|
images.map((i) => [
|
||||||
i.ImageID,
|
i.ImageID,
|
||||||
{
|
{
|
||||||
|
Type: "image",
|
||||||
ImageID: i.ImageID,
|
ImageID: i.ImageID,
|
||||||
ImageName: i.Image.ImageName,
|
ImageName: i.Image.ImageName,
|
||||||
Status: i.Status,
|
Status: i.Status,
|
||||||
|
@ -3,6 +3,7 @@ import {
|
|||||||
type Component,
|
type Component,
|
||||||
type ParentProps,
|
type ParentProps,
|
||||||
createContext,
|
createContext,
|
||||||
|
createEffect,
|
||||||
createMemo,
|
createMemo,
|
||||||
createResource,
|
createResource,
|
||||||
useContext,
|
useContext,
|
||||||
@ -14,12 +15,12 @@ export type SearchImageStore = {
|
|||||||
Array<{ date: Date; images: JustTheImageWhatAreTheseNames }>
|
Array<{ date: Date; images: JustTheImageWhatAreTheseNames }>
|
||||||
>;
|
>;
|
||||||
|
|
||||||
lists: Accessor<Awaited<ReturnType<typeof getUserImages>>["Lists"]>;
|
lists: Accessor<Awaited<ReturnType<typeof getUserImages>>["lists"]>;
|
||||||
|
|
||||||
userImages: Accessor<JustTheImageWhatAreTheseNames>;
|
userImages: Accessor<JustTheImageWhatAreTheseNames>;
|
||||||
|
|
||||||
processingImages: Accessor<
|
processingImages: Accessor<
|
||||||
Awaited<ReturnType<typeof getUserImages>>["ProcessingImages"] | undefined
|
Awaited<ReturnType<typeof getUserImages>>["processingImages"] | undefined
|
||||||
>;
|
>;
|
||||||
|
|
||||||
onRefetchImages: () => void;
|
onRefetchImages: () => void;
|
||||||
@ -29,6 +30,10 @@ const SearchImageContext = createContext<SearchImageStore>();
|
|||||||
export const SearchImageContextProvider: Component<ParentProps> = (props) => {
|
export const SearchImageContextProvider: Component<ParentProps> = (props) => {
|
||||||
const [data, { refetch }] = createResource(getUserImages);
|
const [data, { refetch }] = createResource(getUserImages);
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
console.log(data());
|
||||||
|
});
|
||||||
|
|
||||||
const sortedImages = createMemo<ReturnType<SearchImageStore["imagesByDate"]>>(
|
const sortedImages = createMemo<ReturnType<SearchImageStore["imagesByDate"]>>(
|
||||||
() => {
|
() => {
|
||||||
const d = data();
|
const d = data();
|
||||||
@ -39,7 +44,7 @@ export const SearchImageContextProvider: Component<ParentProps> = (props) => {
|
|||||||
// Sorted by day. But we could potentially add more in the future.
|
// Sorted by day. But we could potentially add more in the future.
|
||||||
const buckets: Record<string, JustTheImageWhatAreTheseNames> = {};
|
const buckets: Record<string, JustTheImageWhatAreTheseNames> = {};
|
||||||
|
|
||||||
for (const image of d.UserImages) {
|
for (const image of d.userImages) {
|
||||||
if (image.CreatedAt == null) {
|
if (image.CreatedAt == null) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -58,14 +63,14 @@ export const SearchImageContextProvider: Component<ParentProps> = (props) => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const processingImages = () => data()?.ProcessingImages ?? [];
|
const processingImages = () => data()?.processingImages ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SearchImageContext.Provider
|
<SearchImageContext.Provider
|
||||||
value={{
|
value={{
|
||||||
imagesByDate: sortedImages,
|
imagesByDate: sortedImages,
|
||||||
lists: () => data()?.Lists ?? [],
|
lists: () => data()?.lists ?? [],
|
||||||
userImages: () => data()?.UserImages ?? [],
|
userImages: () => data()?.userImages ?? [],
|
||||||
processingImages,
|
processingImages,
|
||||||
onRefetchImages: refetch,
|
onRefetchImages: refetch,
|
||||||
}}
|
}}
|
||||||
|
@ -10,6 +10,7 @@ import {
|
|||||||
pipe,
|
pipe,
|
||||||
strictObject,
|
strictObject,
|
||||||
string,
|
string,
|
||||||
|
transform,
|
||||||
union,
|
union,
|
||||||
uuid,
|
uuid,
|
||||||
} from "valibot";
|
} from "valibot";
|
||||||
@ -55,7 +56,7 @@ export const sendImageFile = async (
|
|||||||
file: File,
|
file: File,
|
||||||
): Promise<InferOutput<typeof sendImageResponseValidator>> => {
|
): Promise<InferOutput<typeof sendImageResponseValidator>> => {
|
||||||
const request = getBaseAuthorizedRequest({
|
const request = getBaseAuthorizedRequest({
|
||||||
path: `image/${imageName}`,
|
path: `images/${imageName}`,
|
||||||
body: file,
|
body: file,
|
||||||
method: "POST",
|
method: "POST",
|
||||||
});
|
});
|
||||||
@ -72,7 +73,7 @@ export const sendImage = async (
|
|||||||
base64Image: string,
|
base64Image: string,
|
||||||
): Promise<InferOutput<typeof sendImageResponseValidator>> => {
|
): Promise<InferOutput<typeof sendImageResponseValidator>> => {
|
||||||
const request = getBaseAuthorizedRequest({
|
const request = getBaseAuthorizedRequest({
|
||||||
path: `image/${imageName}`,
|
path: `images/${imageName}`,
|
||||||
body: base64Image,
|
body: base64Image,
|
||||||
method: "POST",
|
method: "POST",
|
||||||
});
|
});
|
||||||
@ -96,7 +97,16 @@ const userImageValidator = strictObject({
|
|||||||
CreatedAt: pipe(string()),
|
CreatedAt: pipe(string()),
|
||||||
ImageID: pipe(string(), uuid()),
|
ImageID: pipe(string(), uuid()),
|
||||||
UserID: pipe(string(), uuid()),
|
UserID: pipe(string(), uuid()),
|
||||||
Image: imageMetaValidator,
|
Image: strictObject({
|
||||||
|
...imageMetaValidator.entries,
|
||||||
|
ImageLists: array(
|
||||||
|
strictObject({
|
||||||
|
ID: pipe(string(), uuid()),
|
||||||
|
ImageID: pipe(string(), uuid()),
|
||||||
|
ListID: pipe(string(), uuid()),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const userProcessingImageValidator = strictObject({
|
const userProcessingImageValidator = strictObject({
|
||||||
@ -118,7 +128,9 @@ const listValidator = strictObject({
|
|||||||
Name: string(),
|
Name: string(),
|
||||||
Description: nullable(string()),
|
Description: nullable(string()),
|
||||||
|
|
||||||
Images: array(
|
Images: pipe(
|
||||||
|
nullable(
|
||||||
|
array(
|
||||||
strictObject({
|
strictObject({
|
||||||
ID: pipe(string(), uuid()),
|
ID: pipe(string(), uuid()),
|
||||||
ImageID: pipe(string(), uuid()),
|
ImageID: pipe(string(), uuid()),
|
||||||
@ -133,6 +145,9 @@ const listValidator = strictObject({
|
|||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
transform((n) => n ?? []),
|
||||||
|
),
|
||||||
|
|
||||||
Schema: strictObject({
|
Schema: strictObject({
|
||||||
ID: pipe(string(), uuid()),
|
ID: pipe(string(), uuid()),
|
||||||
@ -152,9 +167,9 @@ const listValidator = strictObject({
|
|||||||
export type List = InferOutput<typeof listValidator>;
|
export type List = InferOutput<typeof listValidator>;
|
||||||
|
|
||||||
const imageRequestValidator = strictObject({
|
const imageRequestValidator = strictObject({
|
||||||
UserImages: array(userImageValidator),
|
userImages: array(userImageValidator),
|
||||||
ProcessingImages: array(userProcessingImageValidator),
|
processingImages: array(userProcessingImageValidator),
|
||||||
Lists: array(listValidator),
|
lists: array(listValidator),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type JustTheImageWhatAreTheseNames = InferOutput<
|
export type JustTheImageWhatAreTheseNames = InferOutput<
|
||||||
@ -164,18 +179,16 @@ export type JustTheImageWhatAreTheseNames = InferOutput<
|
|||||||
export const getUserImages = async (): Promise<
|
export const getUserImages = async (): Promise<
|
||||||
InferOutput<typeof imageRequestValidator>
|
InferOutput<typeof imageRequestValidator>
|
||||||
> => {
|
> => {
|
||||||
const request = getBaseAuthorizedRequest({ path: "image" });
|
const request = getBaseAuthorizedRequest({ path: "images" });
|
||||||
|
|
||||||
const res = await fetch(request).then((res) => res.json());
|
const res = await fetch(request).then((res) => res.json());
|
||||||
|
|
||||||
console.log("BACKEND RESPONSE: ", res);
|
|
||||||
|
|
||||||
return parse(imageRequestValidator, res);
|
return parse(imageRequestValidator, res);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const postLogin = async (email: string): Promise<void> => {
|
export const postLogin = async (email: string): Promise<void> => {
|
||||||
const request = getBaseRequest({
|
const request = getBaseRequest({
|
||||||
path: "login",
|
path: "auth/login",
|
||||||
body: JSON.stringify({ email }),
|
body: JSON.stringify({ email }),
|
||||||
method: "POST",
|
method: "POST",
|
||||||
});
|
});
|
||||||
@ -183,18 +196,6 @@ export const postLogin = async (email: string): Promise<void> => {
|
|||||||
await fetch(request);
|
await fetch(request);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const postDemoLogin = async (): Promise<
|
|
||||||
InferOutput<typeof codeValidator>
|
|
||||||
> => {
|
|
||||||
const request = getBaseRequest({
|
|
||||||
path: "demo-login",
|
|
||||||
});
|
|
||||||
|
|
||||||
const res = await fetch(request).then((res) => res.json());
|
|
||||||
|
|
||||||
return parse(codeValidator, res);
|
|
||||||
};
|
|
||||||
|
|
||||||
const codeValidator = strictObject({
|
const codeValidator = strictObject({
|
||||||
access: string(),
|
access: string(),
|
||||||
refresh: string(),
|
refresh: string(),
|
||||||
@ -205,7 +206,7 @@ export const postCode = async (
|
|||||||
code: string,
|
code: string,
|
||||||
): Promise<InferOutput<typeof codeValidator>> => {
|
): Promise<InferOutput<typeof codeValidator>> => {
|
||||||
const request = getBaseRequest({
|
const request = getBaseRequest({
|
||||||
path: "code",
|
path: "auth/code",
|
||||||
body: JSON.stringify({ email, code }),
|
body: JSON.stringify({ email, code }),
|
||||||
method: "POST",
|
method: "POST",
|
||||||
});
|
});
|
||||||
@ -214,3 +215,18 @@ export const postCode = async (
|
|||||||
|
|
||||||
return parse(codeValidator, res);
|
return parse(codeValidator, res);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const createList = async (
|
||||||
|
title: string,
|
||||||
|
description: string,
|
||||||
|
): Promise<void> => {
|
||||||
|
const request = getBaseAuthorizedRequest({
|
||||||
|
path: "stacks",
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ title, description }),
|
||||||
|
});
|
||||||
|
|
||||||
|
request.headers.set("Content-Type", "application/json");
|
||||||
|
|
||||||
|
await fetch(request);
|
||||||
|
};
|
||||||
|
@ -1,6 +1,21 @@
|
|||||||
import { literal, pipe, strictObject, string, union, uuid } from "valibot";
|
import { literal, pipe, strictObject, string, union, uuid } from "valibot";
|
||||||
|
|
||||||
|
export const processingListValidator = strictObject({
|
||||||
|
Type: literal("list"),
|
||||||
|
|
||||||
|
Name: string(),
|
||||||
|
ListID: pipe(string(), uuid()),
|
||||||
|
|
||||||
|
Status: union([
|
||||||
|
literal("not-started"),
|
||||||
|
literal("in-progress"),
|
||||||
|
literal("complete"),
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
|
||||||
export const processingImagesValidator = strictObject({
|
export const processingImagesValidator = strictObject({
|
||||||
|
Type: literal("image"),
|
||||||
|
|
||||||
ImageID: pipe(string(), uuid()),
|
ImageID: pipe(string(), uuid()),
|
||||||
ImageName: string(),
|
ImageName: string(),
|
||||||
Status: union([
|
Status: union([
|
||||||
@ -9,3 +24,8 @@ export const processingImagesValidator = strictObject({
|
|||||||
literal("complete"),
|
literal("complete"),
|
||||||
]),
|
]),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const notificationValidator = union([
|
||||||
|
processingListValidator,
|
||||||
|
processingImagesValidator,
|
||||||
|
]);
|
||||||
|
@ -1,47 +1,136 @@
|
|||||||
import { Component, For } from "solid-js";
|
import { Component, For, createSignal } from "solid-js";
|
||||||
import { A } from "@solidjs/router";
|
|
||||||
import { useSearchImageContext } from "@contexts/SearchImageContext";
|
import { useSearchImageContext } from "@contexts/SearchImageContext";
|
||||||
import fastHashCode from "../../utils/hash";
|
import { ListCard } from "@components/list-card";
|
||||||
|
import { Button } from "@kobalte/core/button";
|
||||||
const colors = [
|
import { Dialog } from "@kobalte/core/dialog";
|
||||||
"bg-emerald-50",
|
import { createList } from "../../network";
|
||||||
"bg-lime-50",
|
|
||||||
|
|
||||||
"bg-indigo-50",
|
|
||||||
"bg-sky-50",
|
|
||||||
|
|
||||||
"bg-amber-50",
|
|
||||||
"bg-teal-50",
|
|
||||||
|
|
||||||
"bg-fuchsia-50",
|
|
||||||
"bg-pink-50",
|
|
||||||
];
|
|
||||||
|
|
||||||
export const Categories: Component = () => {
|
export const Categories: Component = () => {
|
||||||
const { lists } = useSearchImageContext();
|
const { lists, onRefetchImages } = useSearchImageContext();
|
||||||
|
|
||||||
|
const [title, setTitle] = createSignal("");
|
||||||
|
const [description, setDescription] = createSignal("");
|
||||||
|
|
||||||
|
const [isCreating, setIsCreating] = createSignal(false);
|
||||||
|
const [showForm, setShowForm] = createSignal(false);
|
||||||
|
|
||||||
|
const handleCreateList = async () => {
|
||||||
|
if (description().trim().length === 0 || title().trim().length === 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
setIsCreating(true);
|
||||||
|
try {
|
||||||
|
await createList(title().trim(), description().trim());
|
||||||
|
setTitle("");
|
||||||
|
setDescription("");
|
||||||
|
setShowForm(false);
|
||||||
|
onRefetchImages(); // Refresh the lists
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to create list:", error);
|
||||||
|
} finally {
|
||||||
|
setIsCreating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="rounded-xl bg-white p-4 flex flex-col gap-2">
|
<div class="rounded-xl bg-white p-4 flex flex-col gap-2">
|
||||||
<h2 class="text-xl font-bold">Generated Lists</h2>
|
<h2 class="text-xl font-bold">Generated Lists</h2>
|
||||||
<div class="w-full grid grid-cols-3 auto-rows-[minmax(100px,1fr)] gap-4">
|
<div class="w-full grid grid-cols-3 auto-rows-[minmax(100px,1fr)] gap-4">
|
||||||
<For each={lists()}>
|
<For each={lists()}>{(list) => <ListCard list={list} />}</For>
|
||||||
{(list) => (
|
</div>
|
||||||
<A
|
|
||||||
href={`/list/${list.ID}`}
|
<div class="mt-4">
|
||||||
class={
|
<Button
|
||||||
"flex flex-col p-4 border border-neutral-200 rounded-lg " +
|
class="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors font-medium shadow-sm hover:shadow-md"
|
||||||
colors[
|
onClick={() => setShowForm(true)}
|
||||||
fastHashCode(list.Name, { forcePositive: true }) %
|
>
|
||||||
colors.length
|
+ Create List
|
||||||
]
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog open={showForm()} onOpenChange={setShowForm}>
|
||||||
|
<Dialog.Portal>
|
||||||
|
<Dialog.Overlay class="fixed inset-0 bg-black/50 z-50" />
|
||||||
|
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
|
<Dialog.Content class="bg-white rounded-lg shadow-xl max-w-md w-full max-h-[90vh] overflow-y-auto">
|
||||||
|
<div class="p-6">
|
||||||
|
<Dialog.Title class="text-xl font-bold text-neutral-900 mb-4">
|
||||||
|
Create New List
|
||||||
|
</Dialog.Title>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
for="list-title"
|
||||||
|
class="block text-sm font-medium text-neutral-700 mb-2"
|
||||||
|
>
|
||||||
|
List Title
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="list-title"
|
||||||
|
type="text"
|
||||||
|
value={title()}
|
||||||
|
onInput={(e) =>
|
||||||
|
setTitle(e.target.value)
|
||||||
|
}
|
||||||
|
placeholder="Enter a title for your list"
|
||||||
|
class="w-full p-3 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-indigo-600 focus:border-transparent transition-colors"
|
||||||
|
disabled={isCreating()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
for="list-description"
|
||||||
|
class="block text-sm font-medium text-neutral-700 mb-2"
|
||||||
|
>
|
||||||
|
List Description
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="list-description"
|
||||||
|
value={description()}
|
||||||
|
onInput={(e) =>
|
||||||
|
setDescription(e.target.value)
|
||||||
|
}
|
||||||
|
placeholder="Describe what kind of list you want to create (e.g., 'A list of my favorite recipes' or 'Photos from my vacation')"
|
||||||
|
class="w-full p-3 border border-neutral-300 rounded-lg resize-none focus:ring-2 focus:ring-indigo-600 focus:border-transparent transition-colors"
|
||||||
|
rows="4"
|
||||||
|
disabled={isCreating()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-3 mt-6">
|
||||||
|
<Button
|
||||||
|
class="flex-1 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors disabled:opacity-50 font-medium shadow-sm hover:shadow-md"
|
||||||
|
onClick={handleCreateList}
|
||||||
|
disabled={
|
||||||
|
isCreating() ||
|
||||||
|
!title().trim() ||
|
||||||
|
!description().trim()
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<p class="text-xl font-bold">{list.Name}</p>
|
{isCreating()
|
||||||
<p class="text-lg">{list.Images.length}</p>
|
? "Creating..."
|
||||||
</A>
|
: "Create List"}
|
||||||
)}
|
</Button>
|
||||||
</For>
|
<Button
|
||||||
|
class="px-4 py-2 bg-neutral-300 text-neutral-700 rounded-lg hover:bg-neutral-400 transition-colors font-medium"
|
||||||
|
onClick={() => {
|
||||||
|
setShowForm(false);
|
||||||
|
setTitle("");
|
||||||
|
setDescription("");
|
||||||
|
}}
|
||||||
|
disabled={isCreating()}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Dialog.Content>
|
||||||
|
</div>
|
||||||
|
</Dialog.Portal>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -1,26 +1,38 @@
|
|||||||
import { ImageComponent } from "@components/image";
|
import { ImageComponentFullHeight } from "@components/image";
|
||||||
import { useSearchImageContext } from "@contexts/SearchImageContext";
|
import { useSearchImageContext } from "@contexts/SearchImageContext";
|
||||||
import { useParams } from "@solidjs/router";
|
import { useParams } from "@solidjs/router";
|
||||||
import { type Component } from "solid-js";
|
import { For, type Component } from "solid-js";
|
||||||
import SolidjsMarkdown from "solidjs-markdown";
|
import SolidjsMarkdown from "solidjs-markdown";
|
||||||
|
import { ListCard } from "@components/list-card";
|
||||||
|
|
||||||
export const ImagePage: Component = () => {
|
export const ImagePage: Component = () => {
|
||||||
const { imageId } = useParams<{ imageId: string }>();
|
const { imageId } = useParams<{ imageId: string }>();
|
||||||
|
|
||||||
const { userImages } = useSearchImageContext();
|
const { userImages, lists } = useSearchImageContext();
|
||||||
|
|
||||||
const image = () => userImages().find((i) => i.ImageID === imageId);
|
const image = () => userImages().find((i) => i.ImageID === imageId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main class="flex flex-col items-center gap-4">
|
<main class="flex flex-col items-center gap-4">
|
||||||
<div class="w-full bg-white rounded-xl p-4">
|
<div class="w-full bg-white rounded-xl p-4">
|
||||||
<ImageComponent ID={imageId} />
|
<ImageComponentFullHeight ID={imageId} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="w-full bg-white rounded-xl p-4 flex flex-col gap-4">
|
||||||
<h2 class="font-bold text-xl">Description</h2>
|
<h2 class="font-bold text-2xl">Description</h2>
|
||||||
|
<div class="grid grid-cols-3 gap-4">
|
||||||
|
<For each={image()?.Image.ImageLists}>
|
||||||
|
{(imageList) => (
|
||||||
|
<ListCard
|
||||||
|
list={lists().find((l) => l.ID === imageList.ListID)!}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="w-full bg-white rounded-xl p-4">
|
||||||
|
<h2 class="font-bold text-2xl">Description</h2>
|
||||||
<SolidjsMarkdown>{image()?.Image.Description}</SolidjsMarkdown>
|
<SolidjsMarkdown>{image()?.Image.Description}</SolidjsMarkdown>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-full grid grid-cols-3 gap-2 grid-flow-row-dense p-4 bg-white rounded-xl"></div>
|
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { ImageComponent } from "@components/image";
|
|
||||||
import { useSearchImageContext } from "@contexts/SearchImageContext";
|
import { useSearchImageContext } from "@contexts/SearchImageContext";
|
||||||
import { useParams } from "@solidjs/router";
|
import { useParams } from "@solidjs/router";
|
||||||
import { Component, For, Show } from "solid-js";
|
import { Component, For, Show } from "solid-js";
|
||||||
|
import { base } from "../../network";
|
||||||
|
|
||||||
export const List: Component = () => {
|
export const List: Component = () => {
|
||||||
const { listId } = useParams();
|
const { listId } = useParams();
|
||||||
@ -13,30 +13,94 @@ export const List: Component = () => {
|
|||||||
return (
|
return (
|
||||||
<Show when={list()} fallback="List could not be found">
|
<Show when={list()} fallback="List could not be found">
|
||||||
{(l) => (
|
{(l) => (
|
||||||
<table>
|
<div class="w-full h-full bg-white rounded-lg shadow-sm border border-neutral-200 overflow-hidden">
|
||||||
<thead>
|
<div class="overflow-x-auto overflow-y-auto h-full">
|
||||||
|
<table class="w-full min-w-full">
|
||||||
|
<thead class="bg-neutral-50 border-b border-neutral-200 sticky top-0 z-10">
|
||||||
<tr>
|
<tr>
|
||||||
<th>Image</th>
|
<th class="px-6 py-4 text-left text-sm font-semibold text-neutral-900 border-r border-neutral-200 min-w-40">
|
||||||
|
Image
|
||||||
|
</th>
|
||||||
<For each={l().Schema.SchemaItems}>
|
<For each={l().Schema.SchemaItems}>
|
||||||
{(item) => <th>{item.Item}</th>}
|
{(item, index) => (
|
||||||
|
<th
|
||||||
|
class={`px-6 py-4 text-left text-sm font-semibold text-neutral-900 min-w-32 ${
|
||||||
|
index() <
|
||||||
|
l().Schema.SchemaItems
|
||||||
|
.length -
|
||||||
|
1
|
||||||
|
? "border-r border-neutral-200"
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.Item}
|
||||||
|
</th>
|
||||||
|
)}
|
||||||
</For>
|
</For>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody class="divide-y divide-neutral-200">
|
||||||
<For each={l().Images}>
|
<For each={l().Images}>
|
||||||
{(image) => (
|
{(image, rowIndex) => (
|
||||||
<tr>
|
<tr
|
||||||
<td>
|
class={`hover:bg-neutral-50 transition-colors ${
|
||||||
<ImageComponent ID={image.ImageID} />
|
rowIndex() % 2 === 0
|
||||||
|
? "bg-white"
|
||||||
|
: "bg-neutral-25"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<td class="px-6 py-4 border-r border-neutral-200">
|
||||||
|
<div class="w-32 h-24 overflow-hidden rounded-lg">
|
||||||
|
<a
|
||||||
|
href={`/image/${image.ImageID}`}
|
||||||
|
class="w-full h-full flex justify-center"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
class="w-full h-full object-cover rounded-lg"
|
||||||
|
src={`${base}/images/${image.ImageID}`}
|
||||||
|
alt="List item"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<For each={image.Items}>
|
<For each={image.Items}>
|
||||||
{(item) => <td>{item.Value}</td>}
|
{(item, colIndex) => (
|
||||||
|
<td
|
||||||
|
class={`px-6 py-4 text-sm text-neutral-700 ${
|
||||||
|
colIndex() <
|
||||||
|
image.Items.length -
|
||||||
|
1
|
||||||
|
? "border-r border-neutral-200"
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="max-w-xs truncate"
|
||||||
|
title={item.Value}
|
||||||
|
>
|
||||||
|
{item.Value}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
</For>
|
</For>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
<Show when={l().Images.length === 0}>
|
||||||
|
<div class="px-6 py-12 text-center text-neutral-500">
|
||||||
|
<p class="text-lg">
|
||||||
|
No images in this list yet
|
||||||
|
</p>
|
||||||
|
<p class="text-sm mt-1">
|
||||||
|
Images will appear here once added to the
|
||||||
|
list
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
);
|
);
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { isTokenValid } from "@components/protected-route";
|
import { isTokenValid } from "@components/protected-route";
|
||||||
import { Button } from "@kobalte/core/button";
|
import { Button } from "@kobalte/core/button";
|
||||||
import { TextField } from "@kobalte/core/text-field";
|
import { TextField } from "@kobalte/core/text-field";
|
||||||
import { postCode, postDemoLogin, postLogin } from "@network/index";
|
import { postCode, postLogin } from "@network/index";
|
||||||
import { Navigate } from "@solidjs/router";
|
import { Navigate } from "@solidjs/router";
|
||||||
import { type Component, Show, createSignal } from "solid-js";
|
import { type Component, Show, createSignal } from "solid-js";
|
||||||
|
|
||||||
@ -18,16 +18,6 @@ export const Login: Component = () => {
|
|||||||
throw new Error("bruh, no email");
|
throw new Error("bruh, no email");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (email.toString() === "demo@email.com") {
|
|
||||||
const { access, refresh } = await postDemoLogin();
|
|
||||||
|
|
||||||
localStorage.setItem("access", access);
|
|
||||||
localStorage.setItem("refresh", refresh);
|
|
||||||
|
|
||||||
window.location.href = "/";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!submitted()) {
|
if (!submitted()) {
|
||||||
await postLogin(email.toString());
|
await postLogin(email.toString());
|
||||||
setSubmitted(true);
|
setSubmitted(true);
|
||||||
|
Reference in New Issue
Block a user