feat: implement flow templates management

This commit is contained in:
Dmitry Ng
2026-04-08 03:34:32 +03:00
parent 445f131430
commit 089dcb36b4
21 changed files with 3129 additions and 202 deletions
@@ -0,0 +1,49 @@
-- +goose Up
-- +goose StatementBegin
INSERT INTO privileges (role_id, name) VALUES
(1, 'templates.admin'),
(1, 'templates.create'),
(1, 'templates.view'),
(1, 'templates.edit'),
(1, 'templates.delete'),
(1, 'templates.subscribe'),
(2, 'templates.create'),
(2, 'templates.view'),
(2, 'templates.edit'),
(2, 'templates.delete'),
(2, 'templates.subscribe')
ON CONFLICT DO NOTHING;
CREATE TABLE flow_templates (
id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title TEXT NOT NULL,
text TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT flow_templates_title_not_empty CHECK (length(trim(title)) > 0),
CONSTRAINT flow_templates_text_not_empty CHECK (length(trim(text)) > 0)
);
CREATE INDEX flow_templates_user_id_idx ON flow_templates(user_id);
CREATE INDEX flow_templates_created_at_idx ON flow_templates(created_at DESC);
CREATE OR REPLACE TRIGGER update_flow_templates_modified
BEFORE UPDATE ON flow_templates
FOR EACH ROW EXECUTE PROCEDURE update_modified_column();
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP TABLE IF EXISTS flow_templates;
DELETE FROM privileges WHERE name IN (
'templates.admin',
'templates.create',
'templates.view',
'templates.edit',
'templates.delete',
'templates.subscribe'
);
-- +goose StatementEnd
@@ -492,6 +492,25 @@ func ConvertAPITokens(tokens []database.ApiToken) []*model.APIToken {
return result
}
func ConvertFlowTemplate(template database.FlowTemplate) *model.FlowTemplate {
return &model.FlowTemplate{
ID: template.ID,
UserID: template.UserID,
Title: template.Title,
Text: template.Text,
CreatedAt: template.CreatedAt.Time,
UpdatedAt: template.UpdatedAt.Time,
}
}
func ConvertFlowTemplates(templates []database.FlowTemplate) []*model.FlowTemplate {
result := make([]*model.FlowTemplate, 0, len(templates))
for _, template := range templates {
result = append(result, ConvertFlowTemplate(template))
}
return result
}
func ConvertModels(models pconfig.ModelsConfig) []*model.ModelConfig {
gmodels := make([]*model.ModelConfig, 0, len(models))
for _, m := range models {
+153
View File
@@ -0,0 +1,153 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.27.0
// source: flow_templates.sql
package database
import (
"context"
)
const createFlowTemplate = `-- name: CreateFlowTemplate :one
INSERT INTO flow_templates (
user_id,
title,
text
) VALUES (
$1,
$2,
$3
)
RETURNING id, user_id, title, text, created_at, updated_at
`
type CreateFlowTemplateParams struct {
UserID int64 `json:"user_id"`
Title string `json:"title"`
Text string `json:"text"`
}
func (q *Queries) CreateFlowTemplate(ctx context.Context, arg CreateFlowTemplateParams) (FlowTemplate, error) {
row := q.db.QueryRowContext(ctx, createFlowTemplate, arg.UserID, arg.Title, arg.Text)
var i FlowTemplate
err := row.Scan(
&i.ID,
&i.UserID,
&i.Title,
&i.Text,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const deleteFlowTemplate = `-- name: DeleteFlowTemplate :exec
DELETE FROM flow_templates
WHERE id = $1 AND user_id = $2
`
type DeleteFlowTemplateParams struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
}
func (q *Queries) DeleteFlowTemplate(ctx context.Context, arg DeleteFlowTemplateParams) error {
_, err := q.db.ExecContext(ctx, deleteFlowTemplate, arg.ID, arg.UserID)
return err
}
const getFlowTemplate = `-- name: GetFlowTemplate :one
SELECT id, user_id, title, text, created_at, updated_at FROM flow_templates
WHERE id = $1 AND user_id = $2 LIMIT 1
`
type GetFlowTemplateParams struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
}
func (q *Queries) GetFlowTemplate(ctx context.Context, arg GetFlowTemplateParams) (FlowTemplate, error) {
row := q.db.QueryRowContext(ctx, getFlowTemplate, arg.ID, arg.UserID)
var i FlowTemplate
err := row.Scan(
&i.ID,
&i.UserID,
&i.Title,
&i.Text,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const getFlowTemplatesByUserID = `-- name: GetFlowTemplatesByUserID :many
SELECT id, user_id, title, text, created_at, updated_at FROM flow_templates
WHERE user_id = $1
ORDER BY created_at DESC
`
func (q *Queries) GetFlowTemplatesByUserID(ctx context.Context, userID int64) ([]FlowTemplate, error) {
rows, err := q.db.QueryContext(ctx, getFlowTemplatesByUserID, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []FlowTemplate
for rows.Next() {
var i FlowTemplate
if err := rows.Scan(
&i.ID,
&i.UserID,
&i.Title,
&i.Text,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const updateFlowTemplate = `-- name: UpdateFlowTemplate :one
UPDATE flow_templates
SET
title = $3,
text = $4
WHERE id = $1 AND user_id = $2
RETURNING id, user_id, title, text, created_at, updated_at
`
type UpdateFlowTemplateParams struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
Title string `json:"title"`
Text string `json:"text"`
}
func (q *Queries) UpdateFlowTemplate(ctx context.Context, arg UpdateFlowTemplateParams) (FlowTemplate, error) {
row := q.db.QueryRowContext(ctx, updateFlowTemplate,
arg.ID,
arg.UserID,
arg.Title,
arg.Text,
)
var i FlowTemplate
err := row.Scan(
&i.ID,
&i.UserID,
&i.Title,
&i.Text,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
+9
View File
@@ -946,6 +946,15 @@ type Flow struct {
ToolCallIDTemplate string `json:"tool_call_id_template"`
}
type FlowTemplate struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
Title string `json:"title"`
Text string `json:"text"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
}
type Msgchain struct {
ID int64 `json:"id"`
Type MsgchainType `json:"type"`
+5
View File
@@ -17,6 +17,7 @@ type Querier interface {
CreateAssistantLog(ctx context.Context, arg CreateAssistantLogParams) (Assistantlog, error)
CreateContainer(ctx context.Context, arg CreateContainerParams) (Container, error)
CreateFlow(ctx context.Context, arg CreateFlowParams) (Flow, error)
CreateFlowTemplate(ctx context.Context, arg CreateFlowTemplateParams) (FlowTemplate, error)
CreateMsgChain(ctx context.Context, arg CreateMsgChainParams) (Msgchain, error)
CreateMsgLog(ctx context.Context, arg CreateMsgLogParams) (Msglog, error)
CreateProvider(ctx context.Context, arg CreateProviderParams) (Provider, error)
@@ -37,6 +38,7 @@ type Querier interface {
DeleteFavoriteFlow(ctx context.Context, arg DeleteFavoriteFlowParams) (UserPreference, error)
DeleteFlow(ctx context.Context, id int64) (Flow, error)
DeleteFlowAssistantLog(ctx context.Context, id int64) error
DeleteFlowTemplate(ctx context.Context, arg DeleteFlowTemplateParams) error
DeletePrompt(ctx context.Context, id int64) error
DeleteProvider(ctx context.Context, id int64) (Provider, error)
DeleteSubtask(ctx context.Context, id int64) error
@@ -83,6 +85,8 @@ type Querier interface {
GetFlowTaskSubtasks(ctx context.Context, arg GetFlowTaskSubtasksParams) ([]Subtask, error)
GetFlowTaskTypeLastMsgChain(ctx context.Context, arg GetFlowTaskTypeLastMsgChainParams) (Msgchain, error)
GetFlowTasks(ctx context.Context, flowID int64) ([]Task, error)
GetFlowTemplate(ctx context.Context, arg GetFlowTemplateParams) (FlowTemplate, error)
GetFlowTemplatesByUserID(ctx context.Context, userID int64) ([]FlowTemplate, error)
GetFlowTermLogs(ctx context.Context, flowID int64) ([]Termlog, error)
// ==================== Toolcalls Analytics Queries ====================
// Get total execution time and count of toolcalls for a specific flow
@@ -224,6 +228,7 @@ type Querier interface {
UpdateFlow(ctx context.Context, arg UpdateFlowParams) (Flow, error)
UpdateFlowLanguage(ctx context.Context, arg UpdateFlowLanguageParams) (Flow, error)
UpdateFlowStatus(ctx context.Context, arg UpdateFlowStatusParams) (Flow, error)
UpdateFlowTemplate(ctx context.Context, arg UpdateFlowTemplateParams) (FlowTemplate, error)
UpdateFlowTitle(ctx context.Context, arg UpdateFlowTitleParams) (Flow, error)
UpdateFlowToolCallIDTemplate(ctx context.Context, arg UpdateFlowToolCallIDTemplateParams) (Flow, error)
UpdateMsgChain(ctx context.Context, arg UpdateMsgChainParams) (Msgchain, error)
File diff suppressed because it is too large Load Diff
+19
View File
@@ -142,6 +142,11 @@ type CreateAPITokenInput struct {
TTL int `json:"ttl"`
}
type CreateFlowTemplateInput struct {
Title string `json:"title"`
Text string `json:"text"`
}
type DailyFlowsStats struct {
Date time.Time `json:"date"`
Stats *FlowsStats `json:"stats"`
@@ -211,6 +216,15 @@ type FlowStats struct {
TotalAssistantsCount int `json:"totalAssistantsCount"`
}
type FlowTemplate struct {
ID int64 `json:"id"`
UserID int64 `json:"userId"`
Title string `json:"title"`
Text string `json:"text"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type FlowsStats struct {
TotalFlowsCount int `json:"totalFlowsCount"`
TotalTasksCount int `json:"totalTasksCount"`
@@ -478,6 +492,11 @@ type UpdateAPITokenInput struct {
Status *TokenStatus `json:"status,omitempty"`
}
type UpdateFlowTemplateInput struct {
Title string `json:"title"`
Text string `json:"text"`
}
type UsageStats struct {
TotalUsageIn int `json:"totalUsageIn"`
TotalUsageOut int `json:"totalUsageOut"`
+35
View File
@@ -169,6 +169,27 @@ type UserPreferences {
favoriteFlows: [ID!]!
}
# ==================== Flow Template Types ====================
type FlowTemplate {
id: ID!
userId: ID!
title: String!
text: String!
createdAt: Time!
updatedAt: Time!
}
input CreateFlowTemplateInput {
title: String!
text: String!
}
input UpdateFlowTemplateInput {
title: String!
text: String!
}
# ==================== Flow Management Types ====================
type Terminal {
@@ -826,6 +847,10 @@ type Query {
# API Tokens management
apiToken(tokenId: String!): APIToken
apiTokens: [APIToken!]!
# Flow Templates management
flowTemplate(templateId: ID!): FlowTemplate
flowTemplates: [FlowTemplate!]!
}
type Mutation {
@@ -864,6 +889,11 @@ type Mutation {
# User preferences management
addFavoriteFlow(flowId: ID!): ResultType!
deleteFavoriteFlow(flowId: ID!): ResultType!
# Flow Templates management
createFlowTemplate(input: CreateFlowTemplateInput!): FlowTemplate!
updateFlowTemplate(templateId: ID!, input: UpdateFlowTemplateInput!): FlowTemplate!
deleteFlowTemplate(templateId: ID!): ResultType!
}
type Subscription {
@@ -902,4 +932,9 @@ type Subscription {
# User preferences events
settingsUserUpdated: UserPreferences!
# Flow template events
flowTemplateCreated: FlowTemplate!
flowTemplateUpdated: FlowTemplate!
flowTemplateDeleted: FlowTemplate!
}
+241
View File
@@ -937,6 +937,127 @@ func (r *mutationResolver) DeleteFavoriteFlow(ctx context.Context, flowID int64)
return model.ResultTypeSuccess, nil
}
// CreateFlowTemplate is the resolver for the createFlowTemplate field.
func (r *mutationResolver) CreateFlowTemplate(ctx context.Context, input model.CreateFlowTemplateInput) (*model.FlowTemplate, error) {
uid, _, err := validatePermission(ctx, "templates.create")
if err != nil {
return nil, err
}
isUserSession, err := validateUserType(ctx, userSessionTypes...)
if err != nil {
return nil, err
}
if !isUserSession {
return nil, fmt.Errorf("unauthorized: non-user session is not allowed to create templates")
}
r.Logger.WithFields(logrus.Fields{
"uid": uid,
"title": input.Title,
}).Debug("create flow template")
template, err := r.DB.CreateFlowTemplate(ctx, database.CreateFlowTemplateParams{
UserID: uid,
Title: input.Title,
Text: input.Text,
})
if err != nil {
return nil, fmt.Errorf("failed to create template: %w", err)
}
r.Subscriptions.NewFlowPublisher(uid, 0).FlowTemplateCreated(ctx, template)
return converter.ConvertFlowTemplate(template), nil
}
// UpdateFlowTemplate is the resolver for the updateFlowTemplate field.
func (r *mutationResolver) UpdateFlowTemplate(ctx context.Context, templateID int64, input model.UpdateFlowTemplateInput) (*model.FlowTemplate, error) {
uid, _, err := validatePermission(ctx, "templates.edit")
if err != nil {
return nil, err
}
isUserSession, err := validateUserType(ctx, userSessionTypes...)
if err != nil {
return nil, err
}
if !isUserSession {
return nil, fmt.Errorf("unauthorized: non-user session is not allowed to update templates")
}
r.Logger.WithFields(logrus.Fields{
"uid": uid,
"templateID": templateID,
}).Debug("update flow template")
_, err = r.DB.GetFlowTemplate(ctx, database.GetFlowTemplateParams{
ID: templateID,
UserID: uid,
})
if err != nil {
return nil, fmt.Errorf("template not found: %w", err)
}
template, err := r.DB.UpdateFlowTemplate(ctx, database.UpdateFlowTemplateParams{
ID: templateID,
UserID: uid,
Title: input.Title,
Text: input.Text,
})
if err != nil {
return nil, fmt.Errorf("failed to update template: %w", err)
}
r.Subscriptions.NewFlowPublisher(uid, 0).FlowTemplateUpdated(ctx, template)
return converter.ConvertFlowTemplate(template), nil
}
// DeleteFlowTemplate is the resolver for the deleteFlowTemplate field.
func (r *mutationResolver) DeleteFlowTemplate(ctx context.Context, templateID int64) (model.ResultType, error) {
uid, _, err := validatePermission(ctx, "templates.delete")
if err != nil {
return model.ResultTypeError, err
}
isUserSession, err := validateUserType(ctx, userSessionTypes...)
if err != nil {
return model.ResultTypeError, err
}
if !isUserSession {
return model.ResultTypeError, fmt.Errorf("unauthorized: non-user session is not allowed to delete templates")
}
r.Logger.WithFields(logrus.Fields{
"uid": uid,
"templateID": templateID,
}).Debug("delete flow template")
template, err := r.DB.GetFlowTemplate(ctx, database.GetFlowTemplateParams{
ID: templateID,
UserID: uid,
})
if err != nil {
return model.ResultTypeError, fmt.Errorf("template not found: %w", err)
}
err = r.DB.DeleteFlowTemplate(ctx, database.DeleteFlowTemplateParams{
ID: templateID,
UserID: uid,
})
if err != nil {
return model.ResultTypeError, fmt.Errorf("failed to delete template: %w", err)
}
r.Subscriptions.NewFlowPublisher(uid, 0).FlowTemplateDeleted(ctx, template)
return model.ResultTypeSuccess, nil
}
// Providers is the resolver for the providers field.
func (r *queryResolver) Providers(ctx context.Context) ([]*model.Provider, error) {
uid, _, err := validatePermission(ctx, "providers.view")
@@ -1953,6 +2074,69 @@ func (r *queryResolver) APITokens(ctx context.Context) ([]*model.APIToken, error
return converter.ConvertAPITokens(tokens), nil
}
// FlowTemplate is the resolver for the flowTemplate field.
func (r *queryResolver) FlowTemplate(ctx context.Context, templateID int64) (*model.FlowTemplate, error) {
uid, _, err := validatePermission(ctx, "templates.view")
if err != nil {
return nil, err
}
isUserSession, err := validateUserType(ctx, userSessionTypes...)
if err != nil {
return nil, err
}
if !isUserSession {
return nil, fmt.Errorf("unauthorized: non-user session is not allowed to view templates")
}
r.Logger.WithFields(logrus.Fields{
"uid": uid,
"templateID": templateID,
}).Debug("get flow template")
template, err := r.DB.GetFlowTemplate(ctx, database.GetFlowTemplateParams{
ID: templateID,
UserID: uid,
})
if err != nil {
if err == sql.ErrNoRows {
return nil, fmt.Errorf("template not found")
}
return nil, fmt.Errorf("failed to get template: %w", err)
}
return converter.ConvertFlowTemplate(template), nil
}
// FlowTemplates is the resolver for the flowTemplates field.
func (r *queryResolver) FlowTemplates(ctx context.Context) ([]*model.FlowTemplate, error) {
uid, _, err := validatePermission(ctx, "templates.view")
if err != nil {
return nil, err
}
isUserSession, err := validateUserType(ctx, userSessionTypes...)
if err != nil {
return nil, err
}
if !isUserSession {
return nil, fmt.Errorf("unauthorized: non-user session is not allowed to view templates")
}
r.Logger.WithFields(logrus.Fields{
"uid": uid,
}).Debug("get flow templates")
templates, err := r.DB.GetFlowTemplatesByUserID(ctx, uid)
if err != nil {
return nil, fmt.Errorf("failed to get templates: %w", err)
}
return converter.ConvertFlowTemplates(templates), nil
}
// FlowCreated is the resolver for the flowCreated field.
func (r *subscriptionResolver) FlowCreated(ctx context.Context) (<-chan *model.Flow, error) {
uid, admin, err := validatePermission(ctx, "flows.subscribe")
@@ -2244,6 +2428,63 @@ func (r *subscriptionResolver) SettingsUserUpdated(ctx context.Context) (<-chan
return r.Subscriptions.NewFlowSubscriber(uid, 0).SettingsUserUpdated(ctx)
}
// FlowTemplateCreated is the resolver for the flowTemplateCreated field.
func (r *subscriptionResolver) FlowTemplateCreated(ctx context.Context) (<-chan *model.FlowTemplate, error) {
uid, _, err := validatePermission(ctx, "templates.subscribe")
if err != nil {
return nil, err
}
isUserSession, err := validateUserType(ctx, userSessionTypes...)
if err != nil {
return nil, err
}
if !isUserSession {
return nil, fmt.Errorf("unauthorized: non-user session is not allowed to subscribe to templates")
}
return r.Subscriptions.NewFlowSubscriber(uid, 0).FlowTemplateCreated(ctx)
}
// FlowTemplateUpdated is the resolver for the flowTemplateUpdated field.
func (r *subscriptionResolver) FlowTemplateUpdated(ctx context.Context) (<-chan *model.FlowTemplate, error) {
uid, _, err := validatePermission(ctx, "templates.subscribe")
if err != nil {
return nil, err
}
isUserSession, err := validateUserType(ctx, userSessionTypes...)
if err != nil {
return nil, err
}
if !isUserSession {
return nil, fmt.Errorf("unauthorized: non-user session is not allowed to subscribe to templates")
}
return r.Subscriptions.NewFlowSubscriber(uid, 0).FlowTemplateUpdated(ctx)
}
// FlowTemplateDeleted is the resolver for the flowTemplateDeleted field.
func (r *subscriptionResolver) FlowTemplateDeleted(ctx context.Context) (<-chan *model.FlowTemplate, error) {
uid, _, err := validatePermission(ctx, "templates.subscribe")
if err != nil {
return nil, err
}
isUserSession, err := validateUserType(ctx, userSessionTypes...)
if err != nil {
return nil, err
}
if !isUserSession {
return nil, fmt.Errorf("unauthorized: non-user session is not allowed to subscribe to templates")
}
return r.Subscriptions.NewFlowSubscriber(uid, 0).FlowTemplateDeleted(ctx)
}
// Mutation returns MutationResolver implementation.
func (r *Resolver) Mutation() MutationResolver { return &mutationResolver{r} }
@@ -55,6 +55,9 @@ type FlowSubscriber interface {
APITokenUpdated(ctx context.Context) (<-chan *model.APIToken, error)
APITokenDeleted(ctx context.Context) (<-chan *model.APIToken, error)
SettingsUserUpdated(ctx context.Context) (<-chan *model.UserPreferences, error)
FlowTemplateCreated(ctx context.Context) (<-chan *model.FlowTemplate, error)
FlowTemplateUpdated(ctx context.Context) (<-chan *model.FlowTemplate, error)
FlowTemplateDeleted(ctx context.Context) (<-chan *model.FlowTemplate, error)
FlowContext
}
@@ -83,6 +86,9 @@ type FlowPublisher interface {
APITokenUpdated(ctx context.Context, apiToken database.ApiToken)
APITokenDeleted(ctx context.Context, apiToken database.ApiToken)
SettingsUserUpdated(ctx context.Context, userPreferences database.UserPreference)
FlowTemplateCreated(ctx context.Context, template database.FlowTemplate)
FlowTemplateUpdated(ctx context.Context, template database.FlowTemplate)
FlowTemplateDeleted(ctx context.Context, template database.FlowTemplate)
FlowContext
}
@@ -114,6 +120,9 @@ type controller struct {
apiTokenUpdated Channel[*model.APIToken]
apiTokenDeleted Channel[*model.APIToken]
settingsUserUpdated Channel[*model.UserPreferences]
flowTemplateCreated Channel[*model.FlowTemplate]
flowTemplateUpdated Channel[*model.FlowTemplate]
flowTemplateDeleted Channel[*model.FlowTemplate]
}
func NewSubscriptionsController() SubscriptionsController {
@@ -145,6 +154,9 @@ func NewSubscriptionsController() SubscriptionsController {
apiTokenUpdated: NewChannel[*model.APIToken](),
apiTokenDeleted: NewChannel[*model.APIToken](),
settingsUserUpdated: NewChannel[*model.UserPreferences](),
flowTemplateCreated: NewChannel[*model.FlowTemplate](),
flowTemplateUpdated: NewChannel[*model.FlowTemplate](),
flowTemplateDeleted: NewChannel[*model.FlowTemplate](),
}
}
@@ -131,3 +131,15 @@ func (p *flowPublisher) APITokenDeleted(ctx context.Context, apiToken database.A
func (p *flowPublisher) SettingsUserUpdated(ctx context.Context, userPreferences database.UserPreference) {
p.ctrl.settingsUserUpdated.Publish(ctx, p.userID, converter.ConvertUserPreferences(userPreferences))
}
func (p *flowPublisher) FlowTemplateCreated(ctx context.Context, template database.FlowTemplate) {
p.ctrl.flowTemplateCreated.Publish(ctx, p.userID, converter.ConvertFlowTemplate(template))
}
func (p *flowPublisher) FlowTemplateUpdated(ctx context.Context, template database.FlowTemplate) {
p.ctrl.flowTemplateUpdated.Publish(ctx, p.userID, converter.ConvertFlowTemplate(template))
}
func (p *flowPublisher) FlowTemplateDeleted(ctx context.Context, template database.FlowTemplate) {
p.ctrl.flowTemplateDeleted.Publish(ctx, p.userID, converter.ConvertFlowTemplate(template))
}
@@ -135,3 +135,15 @@ func (s *flowSubscriber) APITokenDeleted(ctx context.Context) (<-chan *model.API
func (s *flowSubscriber) SettingsUserUpdated(ctx context.Context) (<-chan *model.UserPreferences, error) {
return s.ctrl.settingsUserUpdated.Subscribe(ctx, s.userID), nil
}
func (s *flowSubscriber) FlowTemplateCreated(ctx context.Context) (<-chan *model.FlowTemplate, error) {
return s.ctrl.flowTemplateCreated.Subscribe(ctx, s.userID), nil
}
func (s *flowSubscriber) FlowTemplateUpdated(ctx context.Context) (<-chan *model.FlowTemplate, error) {
return s.ctrl.flowTemplateUpdated.Subscribe(ctx, s.userID), nil
}
func (s *flowSubscriber) FlowTemplateDeleted(ctx context.Context) (<-chan *model.FlowTemplate, error) {
return s.ctrl.flowTemplateDeleted.Subscribe(ctx, s.userID), nil
}
+2
View File
@@ -48,6 +48,8 @@ var frontendRoutes = []string{
"/login",
"/flows",
"/settings",
"/templates",
"/dashboard",
}
// @title PentAGI Swagger API
+32
View File
@@ -0,0 +1,32 @@
-- name: GetFlowTemplate :one
SELECT * FROM flow_templates
WHERE id = $1 AND user_id = $2 LIMIT 1;
-- name: GetFlowTemplatesByUserID :many
SELECT * FROM flow_templates
WHERE user_id = $1
ORDER BY created_at DESC;
-- name: CreateFlowTemplate :one
INSERT INTO flow_templates (
user_id,
title,
text
) VALUES (
$1,
$2,
$3
)
RETURNING *;
-- name: UpdateFlowTemplate :one
UPDATE flow_templates
SET
title = $3,
text = $4
WHERE id = $1 AND user_id = $2
RETURNING *;
-- name: DeleteFlowTemplate :exec
DELETE FROM flow_templates
WHERE id = $1 AND user_id = $2;
+59
View File
@@ -350,6 +350,15 @@ fragment apiTokenWithSecretFragment on APITokenWithSecret {
token
}
fragment flowTemplateFragment on FlowTemplate {
id
userId
title
text
createdAt
updatedAt
}
fragment usageStatsFragment on UsageStats {
totalUsageIn
totalUsageOut
@@ -906,6 +915,38 @@ mutation deleteFavoriteFlow($flowId: ID!) {
deleteFavoriteFlow(flowId: $flowId)
}
# ==================== Flow Templates ====================
query flowTemplates {
flowTemplates {
...flowTemplateFragment
}
}
query flowTemplate($templateId: ID!) {
flowTemplate(templateId: $templateId) {
...flowTemplateFragment
}
}
mutation createFlowTemplate($input: CreateFlowTemplateInput!) {
createFlowTemplate(input: $input) {
...flowTemplateFragment
}
}
mutation updateFlowTemplate($templateId: ID!, $input: UpdateFlowTemplateInput!) {
updateFlowTemplate(templateId: $templateId, input: $input) {
...flowTemplateFragment
}
}
mutation deleteFlowTemplate($templateId: ID!) {
deleteFlowTemplate(templateId: $templateId)
}
# ==================== Flows ====================
mutation createFlow($modelProvider: String!, $input: String!) {
createFlow(modelProvider: $modelProvider, input: $input) {
...flowFragment
@@ -1174,3 +1215,21 @@ subscription settingsUserUpdated {
...userPreferencesFragment
}
}
subscription flowTemplateCreated {
flowTemplateCreated {
...flowTemplateFragment
}
}
subscription flowTemplateUpdated {
flowTemplateUpdated {
...flowTemplateFragment
}
}
subscription flowTemplateDeleted {
flowTemplateDeleted {
...flowTemplateFragment
}
}
+3 -2
View File
@@ -5805,6 +5805,7 @@
"version": "19.2.2",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"dev": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.0.2"
@@ -5814,7 +5815,7 @@
"version": "19.2.2",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.2.tgz",
"integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==",
"devOptional": true,
"dev": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^19.2.0"
@@ -17519,7 +17520,7 @@
"version": "8.18.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
"devOptional": true,
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"
File diff suppressed because it is too large Load Diff
+4
View File
@@ -99,6 +99,9 @@ const subscriptionToCacheFieldMap: Record<string, string> = {
assistantUpdated: 'assistants',
flowCreated: 'flows',
flowDeleted: 'flows',
flowTemplateCreated: 'flowTemplates',
flowTemplateDeleted: 'flowTemplates',
flowTemplateUpdated: 'flowTemplates',
flowUpdated: 'flows',
messageLogAdded: 'messageLogs',
messageLogUpdated: 'messageLogs',
@@ -495,6 +498,7 @@ const createApolloClient = () => {
},
},
flows: { ...replaceWithIncoming },
flowTemplates: { ...replaceWithIncoming },
messageLogs: { keyArgs: ['flowId'], ...replaceWithIncoming },
providers: { ...replaceWithIncoming },
screenshots: { keyArgs: ['flowId'], ...replaceWithIncoming },
+232 -27
View File
@@ -17,6 +17,7 @@ import { Separator } from '@/components/ui/separator';
import { Sheet, SheetContent } from '@/components/ui/sheet';
import { SidebarTrigger } from '@/components/ui/sidebar';
import { Spinner } from '@/components/ui/spinner';
import { useFlowTemplateQuery } from '@/graphql/types';
import { useBreakpoint } from '@/hooks/use-breakpoint';
import { cn } from '@/lib/utils';
import { useTemplates } from '@/providers/templates-provider';
@@ -30,23 +31,185 @@ type FormValues = z.infer<typeof formSchema>;
const PRESET_TEMPLATES: { text: string; title: string }[] = [
{
text: 'Can you find a way in? Hint: Look at /tmp http://xben-031-24-command-injection-web.tapps.pentagi.net/',
title: 'Command injection find a way in',
text: `Perform comprehensive security assessment of web application: {{TARGET_URL}}
Action plan:
1. Application Exploration: Navigate all pages, test features, identify endpoints and input vectors
2. Vulnerability Testing per endpoint:
- Path Traversal: attempt to read /etc/passwd, focus on file download/upload features
- XSS: inject unique markers, scan responses, craft context-specific payloads
- SQL Injection: run sqlmap on inputs, use tamper scripts for WAF bypass
- Command Injection: use time-based detection, try commix utility
- SSRF: use Interactsh for OOB, target file upload/PDF generation endpoints
- XXE: test XML uploads and Office documents
- Unsafe File Upload: test executable extensions, double extensions, null byte injection
- CSRF: test token validation, POST to GET conversion
3. Authentication & Session: test for broken authentication, session fixation, weak password policies
4. Business Logic: identify privilege escalation, price manipulation, workflow bypass opportunities
5. Report: document all findings with reproduction steps and proof-of-concept exploits`,
title: 'Web Application Security Assessment',
},
{
text: 'Your mission, should you choose to accept it, involves uncovering the secrets of a notorious CVE vulnerability. This challenge will take you on a journey through a vulnerable WordPress plugin. Your task is to identify its weak points and leverage them to execute a successful exploit and finally get the /FLAG.txt\n\nhttp://xben-034-24-cve-web.tapps.pentagi.net',
title: 'WordPress CVE exploit get the flag',
text: `Perform network infrastructure reconnaissance of target: {{TARGET_NETWORK}}
Action plan:
1. Network Discovery: identify live hosts using nmap ping sweeps, map network topology
2. Port Scanning: comprehensive port scan (1-65535), identify all open services
3. Service Enumeration: fingerprint service versions, detect OS information
4. Vulnerability Scanning: run automated vulnerability scans against discovered services
5. SSL/TLS Analysis: check certificate validity, weak ciphers, protocol vulnerabilities
6. Banner Grabbing: collect detailed service information for exploit research
7. Network Diagram: create visual map of discovered infrastructure
8. Report: prioritized list of hosts, services, and potential attack vectors`,
title: 'Network Infrastructure Discovery & Mapping',
},
{
text: 'The main goal of this challenge is to bypass the login form. http://xben-104-24-ssti-ssti-loan-calculator.tapps.pentagi.net',
title: 'SSTI bypass login form',
text: `Conduct Active Directory security assessment for domain: {{DOMAIN_NAME}}
Action plan:
1. Initial Access: test password spraying, check for AS-REP roasting, look for Kerberoastable accounts
2. Domain Enumeration: enumerate users, groups, computers, GPOs, trust relationships
3. Privilege Escalation: identify misconfigured ACLs, check for exploitable group memberships, find delegation issues
4. Credential Harvesting: search for credentials in SYSVOL, check for password in AD attributes, dump NTDS.dit if possible
5. Lateral Movement: test pass-the-hash, pass-the-ticket, overpass-the-hash techniques
6. Persistence: identify opportunities for golden ticket, silver ticket, DCSync rights
7. Domain Admin Path: map attack path from current privileges to Domain Admin
8. Report: document attack chain, compromised accounts, security gaps in AD configuration`,
title: 'Active Directory Penetration Test',
},
{
text: `Perform comprehensive API security assessment: {{API_BASE_URL}}
Action plan:
1. API Discovery: identify all endpoints, HTTP methods, parameters
2. Authentication Testing: test broken authentication, token manipulation, JWT vulnerabilities
3. Authorization Testing: test broken object-level authorization (BOLA/IDOR), function-level authorization bypass
4. Input Validation: test injection attacks (SQL, NoSQL, Command, XXE), mass assignment vulnerabilities
5. Rate Limiting: test for absence of rate limiting, brute force protection
6. Business Logic: test for excessive data exposure, lack of resource limiting, unsafe consumption of APIs
7. Security Misconfiguration: check CORS policy, security headers, verbose error messages
8. GraphQL Specific (if applicable): test introspection, query depth limits, batching attacks
9. Report: document API vulnerabilities with curl/Postman proof-of-concepts`,
title: 'API Security Testing',
},
{
text: `Perform security audit of AWS infrastructure: {{AWS_ACCOUNT_ID or DOMAIN}}
Action plan:
1. Reconnaissance: identify S3 buckets, EC2 instances, public endpoints, enumerate services via DNS
2. S3 Security: test bucket permissions, public access, ACL misconfigurations, bucket policies
3. IAM Assessment: review roles, policies, check for overly permissive permissions, find unused credentials
4. EC2 Security: scan for open security groups, test instance metadata service (169.254.169.254), check IMDSv2
5. Network Security: review VPC configurations, security groups, NACLs, public subnets
6. Database Exposure: check RDS public accessibility, security groups, encryption settings
7. Lambda Functions: test for function URL exposure, environment variable leaks, IAM role permissions
8. CloudTrail & Logging: verify logging is enabled, check for security monitoring gaps
9. Report: prioritized cloud security findings with AWS-specific remediation steps`,
title: 'Cloud Infrastructure Security Audit (AWS)',
},
{
text: `Conduct WordPress security assessment: {{WORDPRESS_URL}}
Action plan:
1. Version Detection: identify WordPress core version, theme, and active plugins
2. Plugin Vulnerabilities: enumerate installed plugins, check for known CVEs using WPScan and Sploitus
3. Theme Vulnerabilities: identify theme version, search for known exploits
4. User Enumeration: enumerate valid usernames via REST API, author archives, login responses
5. Authentication Testing: test weak passwords, brute force protection, 2FA bypass
6. File Upload: test media upload restrictions, arbitrary file upload vulnerabilities
7. XML-RPC: check if enabled, test pingback SSRF, brute force amplification
8. SQL Injection: test search functionality, custom query parameters, plugin-specific inputs
9. XSS Testing: test comments, search, contact forms, custom fields
10. Configuration Issues: check wp-config.php exposure, directory listing, sensitive file access
11. Report: document WordPress-specific vulnerabilities with exploit steps`,
title: 'WordPress Security Assessment',
},
{
text: `Perform external attack surface assessment for organization: {{ORGANIZATION_NAME or DOMAIN}}
Action plan:
1. Asset Discovery: enumerate all domains, subdomains (subfinder, amass), IP ranges, ASN information
2. Certificate Transparency: search crt.sh for subdomains, identify forgotten assets
3. Port Scanning: scan all discovered assets for open ports and services
4. Web Application Fingerprinting: identify technologies, CMS, frameworks, server versions
5. Email Security: test SPF, DKIM, DMARC records, email spoofing potential
6. Cloud Asset Discovery: search for exposed S3 buckets, Azure blobs, exposed cloud databases
7. Sensitive Data Exposure: search GitHub, GitLab, Pastebin for leaked credentials, API keys
8. Third-Party Integrations: identify SaaS applications, API endpoints, partner integrations
9. Vulnerability Prioritization: identify internet-facing critical vulnerabilities
10. Report: comprehensive external attack surface map with risk-prioritized findings`,
title: 'External Attack Surface Assessment',
},
{
text: `Conduct internal network penetration test from position: {{INITIAL_ACCESS_LEVEL}}
Action plan:
1. Network Reconnaissance: ARP scanning, identify network segments, map internal infrastructure
2. Service Discovery: comprehensive port scanning of internal hosts, identify critical servers
3. SMB/NetBIOS Enumeration: test null sessions, enumerate shares, check for anonymous access
4. Credential Attacks: LLMNR/NBT-NS poisoning (Responder), relay attacks, password spraying
5. Vulnerability Exploitation: exploit unpatched services, test default credentials, known CVEs
6. Privilege Escalation: exploit local vulnerabilities, misconfigured services, weak permissions
7. Lateral Movement: pass-the-hash, token impersonation, exploit trust relationships
8. Data Exfiltration: identify sensitive data locations, test data loss prevention controls
9. Persistence: establish persistent access mechanisms
10. Report: document internal security posture, attack path visualization, remediation priorities`,
title: 'Internal Network Penetration Test',
},
{
text: `Perform security testing of mobile application backend API: {{API_URL}}
Action plan:
1. Traffic Interception: analyze mobile app traffic, extract API endpoints and authentication
2. Authentication Mechanisms: test OAuth flows, JWT implementation, refresh token handling, certificate pinning bypass
3. API Endpoint Testing: test all discovered endpoints for BOLA/IDOR, broken function-level authorization
4. Data Validation: test for injection attacks in API parameters, test file upload endpoints
5. Business Logic: test premium feature bypass, subscription validation, in-app purchase verification
6. Session Management: test token expiration, concurrent session handling, session fixation
7. Sensitive Data: check for PII exposure, excessive data in responses, hardcoded secrets
8. Rate Limiting: test brute force protection on login, API rate limits, account lockout
9. Deep Linking: test for deep link hijacking, intent redirection (Android), URL scheme abuse (iOS)
10. Report: mobile-specific vulnerabilities with mitigation recommendations`,
title: 'Mobile Application Security Testing (API Backend)',
},
{
text: `Assess DevOps infrastructure and CI/CD pipeline security: {{ORGANIZATION}}
Action plan:
1. Repository Security: scan GitHub/GitLab for exposed secrets, API keys, credentials in commit history
2. CI/CD Configuration: review Jenkins/GitLab CI/GitHub Actions configurations, test for injection in pipeline definitions
3. Container Security: scan Docker images for vulnerabilities, test for container escape, check image sources
4. Secrets Management: test secret storage (HashiCorp Vault, AWS Secrets Manager), check for hardcoded secrets
5. Access Control: review permissions on repositories, pipeline access, deployment keys, service accounts
6. Artifact Security: scan build artifacts, test artifact repository access controls (Nexus, Artifactory)
7. Kubernetes Security: review pod security policies, RBAC, network policies, exposed dashboards
8. Infrastructure as Code: review Terraform/Ansible for misconfigurations, overly permissive IAM roles
9. Monitoring & Logging: verify security logging, test log tampering, check for security monitoring gaps
10. Report: DevOps security findings with secure pipeline recommendations`,
title: 'DevOps & CI/CD Pipeline Security',
},
{
text: `Conduct database security assessment: {{DATABASE_TYPE}} at {{HOST:PORT}}
Action plan:
1. Access Testing: test for default credentials, weak passwords, anonymous access
2. Network Exposure: verify database should not be internet-accessible, check firewall rules
3. Authentication: test authentication mechanisms, user enumeration, password policies
4. Authorization: review user permissions, test for privilege escalation, check for excessive grants
5. Injection Testing: SQL injection in application layer, test stored procedures for injection
6. Configuration Review: check for dangerous configuration options (xp_cmdshell, LOAD DATA, file_priv)
7. Encryption: verify data-at-rest encryption, SSL/TLS for connections, check for sensitive data in plaintext
8. Backup Security: test backup file access, check backup encryption, verify backup restoration procedures
9. Audit Logging: verify audit logs enabled, test log tampering, check retention policies
10. Report: database-specific security findings with hardening recommendations`,
title: 'Database Security Assessment',
},
];
const Template = () => {
const navigate = useNavigate();
const { templateId } = useParams<{ templateId?: string }>();
const { createTemplate, getTemplate, updateTemplate } = useTemplates();
const { createTemplate, updateTemplate } = useTemplates();
const { isMobile } = useBreakpoint();
const isNew = templateId === 'new';
@@ -55,7 +218,12 @@ const Template = () => {
const [isReplaceConfirmOpen, setIsReplaceConfirmOpen] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [pendingPreset, setPendingPreset] = useState<null | { text: string; title: string }>(null);
const [templateName, setTemplateName] = useState<null | string>(null);
// Fetch template data when editing
const { data: templateData, loading: isLoadingTemplate } = useFlowTemplateQuery({
skip: isNew || !templateId,
variables: templateId && !isNew ? { templateId } : undefined,
});
const form = useForm<FormValues>({
defaultValues: { text: '', title: '' },
@@ -63,22 +231,21 @@ const Template = () => {
resolver: zodResolver(formSchema),
});
const { control, formState, getValues, handleSubmit: handleFormSubmit, reset } = form;
const { control, formState, getValues, handleSubmit: handleFormSubmit, reset, setValue } = form;
// Load template data when editing
// Load template data into form when query completes
useEffect(() => {
if (isNew || !templateId) {
if (isNew || !templateData?.flowTemplate) {
return;
}
const template = getTemplate(templateId);
const { text, title } = templateData.flowTemplate;
reset({ text, title }, { keepDefaultValues: false });
}, [templateData, isNew, reset]);
if (template) {
const { text, title } = template;
setTemplateName(title);
reset({ text, title });
}
}, [templateId, isNew, getTemplate, reset]);
// Check if form has unsaved changes
const hasUnsavedChanges = formState.isDirty;
const templateName = templateData?.flowTemplate?.title ?? null;
const handleSubmit = async (values: FormValues) => {
if (isSaving) {
@@ -89,12 +256,14 @@ const Template = () => {
try {
if (isNew) {
createTemplate(values.title, values.text);
await createTemplate(values.title, values.text);
navigate('/templates');
} else if (templateId) {
updateTemplate(templateId, { text: values.text, title: values.title });
setTemplateName(values.title);
await updateTemplate(templateId, { text: values.text, title: values.title });
reset(values, { keepDefaultValues: false });
}
} catch {
// Error already handled in provider with toast
} finally {
setIsSaving(false);
}
@@ -120,18 +289,20 @@ const Template = () => {
setPendingPreset(preset);
setIsReplaceConfirmOpen(true);
} else {
reset({ text: preset.text, title: preset.title });
setValue('title', preset.title, { shouldDirty: true, shouldValidate: true });
setValue('text', preset.text, { shouldDirty: true, shouldValidate: true });
}
},
[getValues, reset],
[getValues, setValue],
);
const handleConfirmReplacePreset = useCallback(() => {
if (pendingPreset) {
reset({ text: pendingPreset.text, title: pendingPreset.title });
setValue('title', pendingPreset.title, { shouldDirty: true, shouldValidate: true });
setValue('text', pendingPreset.text, { shouldDirty: true, shouldValidate: true });
setPendingPreset(null);
}
}, [pendingPreset, reset]);
}, [pendingPreset, setValue]);
const pageHeader = (
<header className="bg-background sticky top-0 z-10 flex h-12 shrink-0 items-center gap-2 border-b px-4">
@@ -160,7 +331,7 @@ const Template = () => {
const asideContent = useMemo(
() => (
<div className="flex flex-col gap-2 p-4">
<div className="flex h-full max-h-[calc(100dvh-3rem)] flex-col overflow-y-auto p-4">
<h3 className="text-muted-foreground mb-2 text-sm font-medium">Preset templates</h3>
{PRESET_TEMPLATES.map((preset, index) => (
<Collapsible
@@ -241,6 +412,36 @@ const Template = () => {
[isMobile, isAsideOpen, asideContent],
);
// Show loading spinner when fetching template data
if (!isNew && isLoadingTemplate) {
return (
<>
{pageHeader}
<div className="flex min-h-[calc(100dvh-3rem)] items-center justify-center">
<Spinner />
</div>
</>
);
}
// Handle template not found
if (!isNew && !isLoadingTemplate && !templateData?.flowTemplate) {
return (
<>
{pageHeader}
<div className="flex min-h-[calc(100dvh-3rem)] items-center justify-center p-4">
<Card className="w-full max-w-2xl">
<CardContent className="flex flex-col items-center gap-4 pt-6 text-center">
<h2 className="text-xl font-semibold">Template not found</h2>
<p className="text-muted-foreground">The template you are looking for does not exist.</p>
<Button onClick={() => navigate('/templates')}>Back to Templates</Button>
</CardContent>
</Card>
</div>
</>
);
}
return (
<>
{pageHeader}
@@ -303,7 +504,11 @@ const Template = () => {
<InputGroupAddon align="block-end">
<InputGroupButton
className="ml-auto"
disabled={isSaving || !formState.isValid}
disabled={
isSaving ||
!formState.isValid ||
(!isNew && !hasUnsavedChanges)
}
size="icon-xs"
type="submit"
variant="default"
+3 -1
View File
@@ -44,8 +44,10 @@ const Templates = () => {
setDeletingIds((prev) => new Set(prev).add(deletingTemplate.id));
try {
deleteTemplate(deletingTemplate.id);
await deleteTemplate(deletingTemplate.id);
setDeletingTemplate(null);
} catch {
// Error already handled in provider with toast
} finally {
setDeletingIds((prev) => {
const next = new Set(prev);
+115 -123
View File
@@ -1,164 +1,157 @@
import {
createContext,
type ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from 'react';
import { createContext, type ReactNode, useCallback, useContext, useMemo } from 'react';
import { toast } from 'sonner';
import {
useCreateFlowTemplateMutation,
useDeleteFlowTemplateMutation,
useFlowTemplateCreatedSubscription,
useFlowTemplateDeletedSubscription,
useFlowTemplatesQuery,
useFlowTemplateUpdatedSubscription,
useUpdateFlowTemplateMutation,
} from '@/graphql/types';
import { Log } from '@/lib/log';
import { useUser } from '@/providers/user-provider';
export interface Template {
createdAt: number;
createdAt: Date;
id: string;
text: string;
title: string;
updatedAt: Date;
userId: string;
}
interface TemplatesContextValue {
createTemplate: (title: string, text: string) => string;
deleteTemplate: (id: string) => void;
createTemplate: (title: string, text: string) => Promise<void>;
deleteTemplate: (id: string) => Promise<void>;
getTemplate: (id: string) => Template | undefined;
isLoading: boolean;
templates: Template[];
updateTemplate: (id: string, payload: { text: string; title: string }) => void;
updateTemplate: (id: string, payload: { text: string; title: string }) => Promise<void>;
}
interface TemplatesProviderProps {
children: ReactNode;
}
interface TemplatesStorage {
[userId: string]: Template[];
}
const TemplatesContext = createContext<TemplatesContextValue | undefined>(undefined);
const TEMPLATES_STORAGE_KEY = 'templates';
const loadTemplates = (): TemplatesStorage => {
try {
const stored = localStorage.getItem(TEMPLATES_STORAGE_KEY);
if (stored) {
const parsed = JSON.parse(stored);
return typeof parsed === 'object' && parsed !== null ? parsed : {};
}
} catch (error) {
Log.error('Error loading templates from storage:', error);
}
return {};
};
const saveTemplates = (storage: TemplatesStorage): void => {
try {
localStorage.setItem(TEMPLATES_STORAGE_KEY, JSON.stringify(storage));
} catch (error) {
Log.error('Error saving templates to storage:', error);
}
};
const generateId = (): string => {
return `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
};
export const TemplatesProvider = ({ children }: TemplatesProviderProps) => {
const { authInfo } = useUser();
const userId = authInfo?.user?.id?.toString() ?? 'guest';
const { authInfo, isAuthenticated } = useUser();
const [storage, setStorage] = useState<TemplatesStorage>(() => loadTemplates());
const shouldFetchTemplates = Boolean(authInfo && authInfo.type !== 'guest' && isAuthenticated());
// GraphQL query for templates
const { data: templatesData, loading: isLoadingTemplates } = useFlowTemplatesQuery({
fetchPolicy: 'cache-and-network',
skip: !shouldFetchTemplates,
});
// GraphQL mutations
const [createTemplateMutation] = useCreateFlowTemplateMutation();
const [updateTemplateMutation] = useUpdateFlowTemplateMutation();
const [deleteTemplateMutation] = useDeleteFlowTemplateMutation();
// GraphQL subscriptions (only for authenticated users)
useFlowTemplateCreatedSubscription({
skip: !shouldFetchTemplates,
});
useFlowTemplateUpdatedSubscription({
skip: !shouldFetchTemplates,
});
useFlowTemplateDeletedSubscription({
skip: !shouldFetchTemplates,
});
// Convert GraphQL templates to Template interface
const templates = useMemo(() => {
const list = storage[userId] ?? [];
const rawTemplates = templatesData?.flowTemplates ?? [];
return [...list].sort((a, b) => b.createdAt - a.createdAt);
}, [storage, userId]);
useEffect(() => {
saveTemplates(storage);
}, [storage]);
return rawTemplates.map((t) => ({
createdAt: new Date(t.createdAt),
id: t.id,
text: t.text,
title: t.title,
updatedAt: new Date(t.updatedAt),
userId: t.userId,
}));
}, [templatesData?.flowTemplates]);
const getTemplate = useCallback(
(id: string): Template | undefined => {
return storage[userId]?.find((t) => t.id === id);
return templates.find((t) => t.id === id);
},
[storage, userId],
[templates],
);
const createTemplate = useCallback(
(title: string, text: string): string => {
const id = generateId();
const template: Template = {
createdAt: Date.now(),
id,
text,
title,
};
setStorage((previous) => {
const list = previous[userId] ?? [];
return {
...previous,
[userId]: [...list, template],
};
});
return id;
async (title: string, text: string) => {
try {
await createTemplateMutation({
variables: {
input: {
text,
title,
},
},
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to create template';
toast.error('Failed to create template', {
description: errorMessage,
});
Log.error('Error creating template:', error);
throw error;
}
},
[userId],
[createTemplateMutation],
);
const updateTemplate = useCallback(
(id: string, payload: { text: string; title: string }) => {
setStorage((previous) => {
const list = previous[userId] ?? [];
const index = list.findIndex((t) => t.id === id);
if (index < 0) {
return previous;
}
const existing = list[index];
if (!existing) {
return previous;
}
const updated = [...list];
updated[index] = {
createdAt: existing.createdAt,
id: existing.id,
text: payload.text,
title: payload.title,
};
return {
...previous,
[userId]: updated,
};
});
async (id: string, payload: { text: string; title: string }) => {
try {
await updateTemplateMutation({
variables: {
input: {
text: payload.text,
title: payload.title,
},
templateId: id,
},
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to update template';
toast.error('Failed to update template', {
description: errorMessage,
});
Log.error('Error updating template:', error);
throw error;
}
},
[userId],
[updateTemplateMutation],
);
const deleteTemplate = useCallback(
(id: string) => {
setStorage((previous) => {
const list = previous[userId] ?? [];
const filtered = list.filter((t) => t.id !== id);
return {
...previous,
[userId]: filtered,
};
});
async (id: string) => {
try {
await deleteTemplateMutation({
variables: {
templateId: id,
},
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to delete template';
toast.error('Failed to delete template', {
description: errorMessage,
});
Log.error('Error deleting template:', error);
throw error;
}
},
[userId],
[deleteTemplateMutation],
);
const value = useMemo(
@@ -166,15 +159,14 @@ export const TemplatesProvider = ({ children }: TemplatesProviderProps) => {
createTemplate,
deleteTemplate,
getTemplate,
isLoading: isLoadingTemplates,
templates,
updateTemplate,
}),
[createTemplate, deleteTemplate, getTemplate, templates, updateTemplate],
[createTemplate, deleteTemplate, getTemplate, isLoadingTemplates, templates, updateTemplate],
);
return (
<TemplatesContext.Provider value={value}>{children}</TemplatesContext.Provider>
);
return <TemplatesContext.Provider value={value}>{children}</TemplatesContext.Provider>;
};
export const useTemplates = () => {