package config
import (
"fmt"
"os"
"strconv"
_ "github.com/joho/godotenv/autoload"
)
type DBConfig struct {
PostgresUserRO string
PostgresPasswordRO string
PostgresDBRO string
PostgresHostRO string
PostgresPortRO string
PostgresUserRW string
PostgresPasswordRW string
PostgresDBRW string
PostgresHostRW string
PostgresPortRW string
}
type ServerConfig struct {
BackendAddress string
BackendPort string
RunnerGrpcAddress string
RunnerGrpcPort string
RedisHost string
RedisPort string
}
type AppConfig struct {
PatentURLMaxAttempts int16
JWTSecret string
MaxWorkspacesPerUser int
ProxyFilePath string
}
type Config struct {
DB DBConfig
Server ServerConfig
App AppConfig
}
func Load() (Config, error) {
maxAttempts, err := strconv.Atoi(
getEnv("PATENT_URL_SCRAPE_MAX_ATTEMPTS", "3"),
)
if err != nil {
return Config{}, fmt.Errorf(
"can't convert value of PATENT_URL_SCRAPE_MAX_ATTEMPTS to int, check .env",
)
}
maxWorkspaces, err := strconv.Atoi(
getEnv("MAX_WORKSPACES_PER_USER", "1"),
)
if err != nil {
return Config{}, fmt.Errorf(
"can't convert value of MAX_WORKSPACES_PER_USER to int, check .env",
)
}
db := DBConfig{
PostgresUserRO: getEnv("POSTGRES_USER_RO", "postgres"),
PostgresPasswordRO: getEnv("POSTGRES_PASSWORD_RO", ""),
PostgresDBRO: getEnv("POSTGRES_DB_RO", "postgres"),
PostgresHostRO: getEnv("POSTGRES_HOST_RO", "localhost"),
PostgresPortRO: getEnv("POSTGRES_PORT_RO", "5432"),
PostgresUserRW: getEnv("POSTGRES_USER_RW", "postgres"),
PostgresPasswordRW: getEnv("POSTGRES_PASSWORD_RW", ""),
PostgresDBRW: getEnv("POSTGRES_DB_RW", "postgres"),
PostgresHostRW: getEnv("POSTGRES_HOST_RW", "localhost"),
PostgresPortRW: getEnv("POSTGRES_PORT_RW", "5432"),
}
server := ServerConfig{
BackendAddress: getEnv("BACKEND_ADDRESS", "http://localhost"),
BackendPort: getEnv("BACKEND_PORT", "8080"),
RunnerGrpcAddress: getEnv("RUNNER_GRPC_ADDRESS", "localhost"),
RunnerGrpcPort: getEnv("RUNNER_GRPC_PORT", "9090"),
RedisHost: getEnv("REDIS_HOST", "localhost"),
RedisPort: getEnv("REDIS_PORT", "6379"),
}
app := AppConfig{
PatentURLMaxAttempts: int16(maxAttempts),
JWTSecret: getEnv("JWT_SECRET", "-"),
MaxWorkspacesPerUser: maxWorkspaces,
ProxyFilePath: getEnv("PROXY_FILE_PATH", "proxy.txt"),
}
return Config{
DB: db,
Server: server,
App: app,
}, nil
}
func LoadTestConfig() Config {
return Config{
DB: DBConfig{
PostgresUserRO: "user",
PostgresPasswordRO: "password",
PostgresDBRO: "testdb",
PostgresHostRO: "localhost",
PostgresPortRO: "5432",
PostgresUserRW: "user",
PostgresPasswordRW: "password",
PostgresDBRW: "testdb",
PostgresHostRW: "localhost",
PostgresPortRW: "5432",
},
Server: ServerConfig{
BackendAddress: "http://localhost",
BackendPort: "8080",
RunnerGrpcAddress: "localhost",
RunnerGrpcPort: "9090",
RedisHost: "localhost",
RedisPort: "6379",
},
App: AppConfig{
PatentURLMaxAttempts: 1,
JWTSecret: "test_secret",
MaxWorkspacesPerUser: 1,
ProxyFilePath: "proxy.txt",
},
}
}
func getEnv(key, defaultVal string) string {
if val := os.Getenv(key); val != "" {
return val
}
return defaultVal
}
package db
import (
"context"
"fmt"
"codeberg.org/at375/patent-flow/internal/config"
"github.com/jackc/pgx/v5/pgxpool"
)
func ConnectPostgresRO(ctx context.Context, cfg config.DBConfig) (*pgxpool.Pool, error) {
connStr := buildPostgresConnString(cfg.PostgresUserRO, cfg.PostgresPasswordRO, cfg.PostgresHostRO, cfg.PostgresPortRO, cfg.PostgresDBRO)
pool, err := pgxpool.New(ctx, connStr)
if err != nil {
return nil, err
}
if err := pool.Ping(ctx); err != nil {
return nil, err
}
return pool, nil
}
func ConnectPostgresRW(ctx context.Context, cfg config.DBConfig) (*pgxpool.Pool, error) {
connStr := buildPostgresConnString(cfg.PostgresUserRW, cfg.PostgresPasswordRW, cfg.PostgresHostRW, cfg.PostgresPortRW, cfg.PostgresDBRW)
pool, err := pgxpool.New(ctx, connStr)
if err != nil {
return nil, err
}
if err := pool.Ping(ctx); err != nil {
return nil, err
}
return pool, nil
}
func buildPostgresConnString(user string, password string, host string, port string, dbname string) string {
return fmt.Sprintf(
"postgres://%s:%s@%s:%s/%s?sslmode=disable",
user, password, host, port, dbname,
)
}
package handler
import (
"net/http"
"net/mail"
"strconv"
"codeberg.org/at375/patent-flow/internal/store"
"github.com/labstack/echo/v5"
)
func (h *Handler) ListAgentsHandler(c *echo.Context) error {
params, err := store.ParseListParams(
c.QueryParams(),
store.AllowedAgentSorts,
store.AllowedAgentFilters,
)
if err != nil {
return h.error(c, http.StatusBadRequest, err, err.Error())
}
ctx := ctxWithRefresh(c)
page, err := h.db.ListAgents(ctx, params)
if err != nil {
return h.error(c, http.StatusInternalServerError, err, "failed to list agents")
}
return c.JSON(http.StatusOK, page)
}
func (h *Handler) UpdateAgentEmailHandler(c *echo.Context) error {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
return h.error(c, http.StatusBadRequest, err, "invalid agent id")
}
var email string
if err := c.Bind(&email); err != nil {
return h.error(c, http.StatusBadRequest, err, "invalid JSON input")
}
_, err = mail.ParseAddress(email)
if err != nil {
return h.error(c, http.StatusBadRequest, err, "invalid email")
}
if err := h.db.UpdateAgentEmail(c.Request().Context(), id, email); err != nil {
return h.error(c, http.StatusInternalServerError, err, "failed to update agent email")
}
return c.NoContent(http.StatusOK)
}
func (h *Handler) ResetAgentEmailHandler(c *echo.Context) error {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
return h.error(c, http.StatusBadRequest, err, "invalid agent id")
}
if err := h.db.UpdateAgentEmail(c.Request().Context(), id, ""); err != nil {
return h.error(c, http.StatusInternalServerError, err, "failed to reset agent email")
}
return c.NoContent(http.StatusOK)
}
func (h *Handler) GetAgentHandler(c *echo.Context) error {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
return h.error(c, http.StatusBadRequest, err, "invalid agent id")
}
ctx := ctxWithRefresh(c)
agent, err := h.db.GetAgent(ctx, id)
if err != nil {
return h.error(c, http.StatusInternalServerError, err, "failed to get agent")
}
return c.JSON(http.StatusOK, agent)
}
package handler
import (
"net/http"
"strconv"
"codeberg.org/at375/patent-flow/internal/store"
"github.com/labstack/echo/v5"
)
func (h *Handler) ListApplicantsHandler(c *echo.Context) error {
params, err := store.ParseListParams(
c.QueryParams(),
store.AllowedApplicantSorts,
store.AllowedApplicantFilters,
)
if err != nil {
return h.error(c, http.StatusBadRequest, err, err.Error())
}
ctx := ctxWithRefresh(c)
page, err := h.db.ListApplicants(ctx, params)
if err != nil {
return h.error(c, http.StatusInternalServerError, err, "failed to list applicants")
}
return c.JSON(http.StatusOK, page)
}
func (h *Handler) GetApplicantHandler(c *echo.Context) error {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
return h.error(c, http.StatusBadRequest, err, "invalid applicant id")
}
ctx := ctxWithRefresh(c)
applicant, err := h.db.GetApplicant(ctx, id)
if err != nil {
return h.error(c, http.StatusInternalServerError, err, "failed to get applicant")
}
return c.JSON(http.StatusOK, applicant)
}
package handler
import (
"errors"
"fmt"
"net/http"
"net/mail"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"github.com/labstack/echo/v5"
"golang.org/x/crypto/bcrypt"
)
type LoginRequest struct {
Email string `json:"email"`
Password string `json:"password"`
RememberMe bool `json:"remember_me"`
}
type AuthRole string
const (
AdminRole AuthRole = "admin"
UserRole AuthRole = "user"
)
type ForbiddenError struct {
Message string
}
func (e *ForbiddenError) Error() string {
return e.Message
}
func ErrForbidden(msg string) error {
return &ForbiddenError{Message: "forbidden: " + msg}
}
func (h *Handler) LoginHandler(c *echo.Context) error {
if c.Request().Header.Get(echo.HeaderContentType) != echo.MIMEApplicationJSON {
return c.NoContent(http.StatusUnsupportedMediaType)
}
req := new(LoginRequest)
if err := c.Bind(req); err != nil {
return h.error(c, http.StatusBadRequest, err, "Invalid request")
}
if req.Password == "" {
return h.error(c, http.StatusBadRequest, fmt.Errorf("password not supplied"), "Invalid request")
}
_, err := mail.ParseAddress(req.Email)
if err != nil {
return h.error(c, http.StatusBadRequest, err, "Invalid email")
}
user, err := h.db.GetUserByEmail(c.Request().Context(), req.Email)
if err != nil {
return h.error(c, http.StatusUnauthorized, err, "Invalid credentials")
}
err = bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password))
if err != nil {
return h.error(c, http.StatusUnauthorized, err, "Invalid credentials")
}
token := jwt.New(jwt.SigningMethodHS256)
claims := token.Claims.(jwt.MapClaims)
claims["user_id"] = user.ID
claims["email"] = user.Email
claims["role"] = user.Role
duration := 1 * time.Hour
if req.RememberMe { // TODO: temp, need to implement different mechanism for long sessions
duration = 2 * 24 * time.Hour
}
claims["exp"] = time.Now().Add(duration).Unix()
claims["jti"] = uuid.NewString()
tokenString, err := token.SignedString([]byte(h.cfg.JWTSecret))
if err != nil {
return h.error(c, http.StatusInternalServerError, err, "Failed to generate token")
}
cookie := &http.Cookie{
Name: "pfjwttoken",
Value: tokenString,
Expires: time.Now().Add(duration),
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
Path: "/",
}
c.SetCookie(cookie)
return c.JSON(http.StatusOK, map[string]any{
"message": "Login successful",
"user": map[string]any{
"email": user.Email,
"role": user.Role,
},
})
}
func (h *Handler) MeHandler(c *echo.Context) error {
token, ok := c.Get("user").(*jwt.Token)
if !ok {
return c.NoContent(http.StatusUnauthorized)
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
return c.NoContent(http.StatusUnauthorized)
}
_, ok = claims["user_id"].(float64)
if !ok {
return c.NoContent(http.StatusUnauthorized)
}
email, ok := claims["email"].(string)
if !ok {
return c.NoContent(http.StatusUnauthorized)
}
role, ok := claims["role"].(string)
if !ok {
return c.NoContent(http.StatusUnauthorized)
}
return c.JSON(http.StatusOK, map[string]any{
"email": email,
"role": role,
})
}
func (h *Handler) userIDFromJWT(c *echo.Context) (int32, error) {
token, ok := c.Get("user").(*jwt.Token)
if !ok {
return 0, errors.New("missing token")
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
return 0, errors.New("invalid claims")
}
id, ok := claims["user_id"].(float64)
if !ok {
return 0, errors.New("missing user_id claim")
}
return int32(id), nil
}
func (h *Handler) userRoleFromJWT(c *echo.Context) (AuthRole, error) {
token, ok := c.Get("user").(*jwt.Token)
if !ok {
return "", errors.New("missing token")
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
return "", errors.New("invalid claims")
}
role, ok := claims["role"].(string)
if !ok {
return "", errors.New("missing role claim")
}
return AuthRole(role), nil
}
func (h *Handler) isAdmin(c *echo.Context) (bool, error) {
role, err := h.userRoleFromJWT(c)
if err != nil {
return false, err
}
isadmin := AuthRole(role) == AdminRole
if !isadmin {
err = ErrForbidden("admin role required")
}
return AuthRole(role) == AdminRole, err
}
package handler
import (
"fmt"
"net/http"
"codeberg.org/at375/patent-flow/internal/model"
"github.com/labstack/echo/v5"
)
func (h *Handler) GetGazetteHandler(c *echo.Context) error {
gaz, err := h.validateGazetteIDParam(c)
if err != nil {
return h.error(c, http.StatusBadRequest, err, err.Error())
}
return c.JSON(http.StatusOK, gaz)
}
func (h *Handler) UpdateGazetteByYearHandler(c *echo.Context) error {
year, err := h.validateYearParam(c.Param("year"))
if err != nil {
return h.error(c, http.StatusBadRequest, err, err.Error())
}
proxy, err := h.proxyProvider.GetRandom()
if err != nil {
return h.error(c, http.StatusInternalServerError, err, "failed to get proxy for gazette fetcher")
}
gaz, err := h.gazFetcher(year, proxy)
if err != nil {
return h.error(c, http.StatusInternalServerError, err, "failed to fetch gazette")
}
res := make([]model.Gazette, len(gaz))
for i, g := range gaz {
r, err := h.db.CreateOrGetGazette(
c.Request().Context(),
year,
g.Name,
g.Date,
g.Count,
)
if err != nil {
return h.error(c, http.StatusInternalServerError, err, fmt.Sprintf("failed to create gazette: %v", g.Name))
}
res[i] = r
}
return c.JSON(http.StatusOK, res)
}
func (h *Handler) GetGazetteByYearHandler(c *echo.Context) error {
year, err := h.validateYearParam(c.Param("year"))
if err != nil {
return h.error(c, http.StatusBadRequest, err, err.Error())
}
ctx := ctxWithRefresh(c)
g, err := h.db.GetGazettesByYear(ctx, year)
if err != nil {
return h.error(c, http.StatusInternalServerError, err, "failed to get gazettes")
}
return c.JSON(http.StatusOK, g)
}
func (h *Handler) validateYearParam(year string) (string, error) {
if year == "" {
return "", fmt.Errorf("year is required")
}
if len(year) != 4 {
return "", fmt.Errorf("year must be 4 digits")
}
if year[0] != '2' && year[0] != '1' {
return "", fmt.Errorf("year must start with 2 or 1")
}
return year, nil
}
package handler
import (
"context"
"net/http"
"codeberg.org/at375/patent-flow/internal/config"
"codeberg.org/at375/patent-flow/internal/store"
"codeberg.org/at375/patent-flow/internal/util/gazettehelper"
"codeberg.org/at375/patent-flow/internal/util/paturlhelper"
"codeberg.org/at375/patent-flow/logger"
"codeberg.org/at375/patent-flow/util/proxyhelper"
"codeberg.org/at375/pfjobrunner/gen/pbjobrunner"
"github.com/labstack/echo/v5"
)
// TODO: organize
type PFHandler interface {
LoginHandler(c *echo.Context) error
MeHandler(c *echo.Context) error
GetGazetteByYearHandler(c *echo.Context) error
GetGazetteHandler(c *echo.Context) error
UpdateGazetteByYearHandler(c *echo.Context) error
GetPatentUrlsByGazetteHandler(c *echo.Context) error
GetPatentUrlsCountByGazetteHandler(c *echo.Context) error
UpdatePatentUrlsByGazetteHandler(c *echo.Context) error
AddJobHandler(c *echo.Context) error
DeleteJobHandler(c *echo.Context) error
PauseJobHandler(c *echo.Context) error
ResumeJobHandler(c *echo.Context) error
StopJobHandler(c *echo.Context) error
StartJobHandler(c *echo.Context) error
SetOrderHandler(c *echo.Context) error
SetPatentDeadlineHandler(c *echo.Context) error
ListWorkspacesHandler(c *echo.Context) error
CreateWorkspaceHandler(c *echo.Context) error
RenameWorkspaceHandler(c *echo.Context) error
DeleteWorkspaceHandler(c *echo.Context) error
GetWorkspaceGraphHandler(c *echo.Context) error
AddNodeHandler(c *echo.Context) error
CopyNodeHandler(c *echo.Context) error
DeleteNodeHandler(c *echo.Context) error
UpdateNodePositionHandler(c *echo.Context) error
UpdateNoteContentHandler(c *echo.Context) error
MoveNodeHandler(c *echo.Context) error
RefreshNodeMetadataHandler(c *echo.Context) error
AddEdgeHandler(c *echo.Context) error
DeleteEdgeHandler(c *echo.Context) error
BulkDeleteNodesHandler(c *echo.Context) error
BulkMoveNodesHandler(c *echo.Context) error
BulkCopyNodesHandler(c *echo.Context) error
BulkUpdateNodePositionsHandler(c *echo.Context) error
ListPatentsHandler(c *echo.Context) error
ListApplicantsHandler(c *echo.Context) error
ListAgentsHandler(c *echo.Context) error
GetPatentHandler(c *echo.Context) error
GetApplicantHandler(c *echo.Context) error
GetAgentHandler(c *echo.Context) error
UpdatePatentDeadlineDateHandler(c *echo.Context) error
UpdateAgentEmailHandler(c *echo.Context) error
ResetAgentEmailHandler(c *echo.Context) error
JWTSecret() []byte
AdminOnlyMiddleware() echo.MiddlewareFunc
}
type Handler struct {
cfg config.AppConfig
db store.Store
logger logger.Logger
runnerClient pbjobrunner.JobRunnerServiceClient
proxyProvider proxyhelper.ProxyProvider
gazFetcher gazettehelper.GazetteFetcherFunc
patUrlFetcher paturlhelper.PatentUrlFetcherFunc
}
func NewHandler(s store.Store, logger logger.Logger, cfg config.AppConfig, runnerClient pbjobrunner.JobRunnerServiceClient, proxyProvider proxyhelper.ProxyProvider) *Handler {
return &Handler{
cfg: cfg,
db: s,
logger: logger,
runnerClient: runnerClient,
proxyProvider: proxyProvider,
gazFetcher: gazettehelper.FetchGazettesByYear,
patUrlFetcher: paturlhelper.FetchPatentURLsByGazette,
}
}
func (h *Handler) JWTSecret() []byte {
return []byte(h.cfg.JWTSecret)
}
func (h *Handler) error(c *echo.Context, code int, err error, msg string) error {
h.logger.Error(c.Request().Context(), msg, map[string]any{
"error": err.Error(),
"path": c.Request().URL.Path,
})
return c.JSON(code, map[string]string{"error": msg})
}
func ctxWithRefresh(c *echo.Context) context.Context {
ctx := c.Request().Context()
if c.QueryParam("refresh") == "true" {
ctx = context.WithValue(ctx, store.CacheRefreshKey{}, true)
}
return ctx
}
func (h *Handler) AdminOnlyMiddleware() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c *echo.Context) error {
if ok, err := h.isAdmin(c); !ok {
return h.error(c, http.StatusForbidden, err, err.Error())
}
return next(c)
}
}
}
package handler
import (
"net/http"
"strconv"
"time"
"codeberg.org/at375/patent-flow/internal/store"
"github.com/labstack/echo/v5"
)
func (h *Handler) ListPatentsHandler(c *echo.Context) error {
params, err := store.ParseListParams(
c.QueryParams(),
store.AllowedPatentSorts,
store.AllowedPatentFilters,
)
if err != nil {
return h.error(c, http.StatusBadRequest, err, err.Error())
}
ctx := ctxWithRefresh(c)
page, err := h.db.ListPatents(ctx, params)
if err != nil {
return h.error(c, http.StatusInternalServerError, err, "failed to list patents")
}
return c.JSON(http.StatusOK, page)
}
func (h *Handler) UpdatePatentDeadlineDateHandler(c *echo.Context) error {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
return h.error(c, http.StatusBadRequest, err, "invalid patent id")
}
var deadline string
if err := c.Bind(&deadline); err != nil {
return h.error(c, http.StatusBadRequest, err, "invalid JSON input")
}
date, err := time.Parse(time.DateOnly, deadline)
if err != nil {
return h.error(c, http.StatusBadRequest, err, "invalid date format, expected YYYY-MM-DD")
}
if err := h.db.UpdatePatentDeadlineDate(c.Request().Context(), id, date); err != nil {
return h.error(c, http.StatusInternalServerError, err, "failed to update patent deadline date")
}
return c.NoContent(http.StatusOK)
}
func (h *Handler) GetPatentHandler(c *echo.Context) error {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
return h.error(c, http.StatusBadRequest, err, "invalid patent id")
}
ctx := ctxWithRefresh(c)
patent, err := h.db.GetPatentWithRelations(ctx, id)
if err != nil {
return h.error(c, http.StatusInternalServerError, err, "failed to get patent")
}
return c.JSON(http.StatusOK, patent)
}
package handler
import (
"errors"
"fmt"
"net/http"
"strconv"
"codeberg.org/at375/patent-flow/internal/model"
"codeberg.org/at375/pfjobrunner/gen/pbjobrunner"
"github.com/labstack/echo/v5"
)
type PatentUrlsPaginated struct {
Page int `json:"page"`
PageSize int `json:"page_size"`
PatUrls []model.PatentUrl `json:"patent_urls"`
}
func (h *Handler) UpdatePatentUrlsByGazetteHandler(c *echo.Context) error {
gaz, err := h.validateGazetteIDParam(c)
if err != nil {
return h.error(c, http.StatusBadRequest, err, err.Error())
}
maxAttempts := h.cfg.PatentURLMaxAttempts
proxy, err := h.proxyProvider.GetRandom()
if err != nil {
return h.error(c, http.StatusInternalServerError, err, "failed to get proxy for patent urls fetcher")
}
pats, err := h.patUrlFetcher(gaz.Name, proxy)
if err != nil {
return h.error(c, http.StatusInternalServerError, err, "failed to fetch patent urls")
}
err = h.db.CreatePatentUrls(c.Request().Context(), gaz.ID, maxAttempts, pats)
if err != nil {
return h.error(c, http.StatusInternalServerError, err, "failed to insert patent urls")
}
return c.NoContent(http.StatusOK)
}
func (h *Handler) GetPatentUrlsByGazetteHandler(c *echo.Context) error {
page, err := strconv.Atoi(c.QueryParam("page"))
if err != nil {
return h.error(c, http.StatusBadRequest, err, "invalid page supplied")
}
pageSize, err := strconv.Atoi(c.QueryParam("page_size"))
if err != nil {
return h.error(c, http.StatusBadRequest, err, "invalid page size supplied")
}
gaz, err := h.validateGazetteIDParam(c)
if err != nil {
return h.error(c, http.StatusBadRequest, err, err.Error())
}
p, err := h.db.GetPatentUrlsByGazettePage(c.Request().Context(), gaz.ID, page, pageSize)
if err != nil {
return h.error(c, http.StatusInternalServerError, err, "failed to get patent urls")
}
return c.JSON(http.StatusOK, p)
}
func (h *Handler) GetPatentUrlsCountByGazetteHandler(c *echo.Context) error {
gaz, err := h.validateGazetteIDParam(c)
if err != nil {
return h.error(c, http.StatusBadRequest, err, err.Error())
}
count, err := h.db.GetPatentUrlsCount(c.Request().Context(), gaz.ID)
if err != nil {
return h.error(c, http.StatusInternalServerError, err, "Could not get count for "+string(gaz.ID))
}
return c.JSON(http.StatusOK, map[string]int64{"count": count})
}
func (h *Handler) AddJobHandler(c *echo.Context) error {
ctx := c.Request().Context()
gaz, err := h.validateGazetteIDParam(c)
if err != nil {
return h.error(c, http.StatusBadRequest, err, err.Error())
}
total, err := h.db.GetPatentUrlsCount(ctx, gaz.ID)
if err != nil {
return h.error(c, http.StatusInternalServerError, err, "failed to get patent urls count")
}
completed, err := h.db.GetPatentUrlsCompletedCount(ctx, gaz.ID)
if err != nil {
return h.error(c, http.StatusInternalServerError, err, "failed to get completed count")
}
failed, err := h.db.GetPatentUrlsFailedCount(ctx, gaz.ID)
if err != nil {
return h.error(c, http.StatusInternalServerError, err, "failed to get failed count")
}
_, err = h.runnerClient.AddJob(ctx, &pbjobrunner.AddJobRequest{
GazetteId: gaz.ID,
Name: gaz.Name,
Total: total,
Completed: completed,
Failed: failed,
})
if err != nil {
return h.error(c, http.StatusBadRequest, err, err.Error())
}
return c.NoContent(http.StatusAccepted)
}
func (h *Handler) DeleteJobHandler(c *echo.Context) error {
valID, err := strconv.Atoi(c.Param("gazette-id"))
if err != nil {
return h.error(c, http.StatusBadRequest, err, "invalid gazette id format")
}
_, err = h.runnerClient.DeleteJob(c.Request().Context(), &pbjobrunner.DeleteJobRequest{
GazetteId: int32(valID),
})
if err != nil {
return h.error(c, http.StatusBadRequest, err, err.Error())
}
return c.NoContent(http.StatusAccepted)
}
func (h *Handler) PauseJobHandler(c *echo.Context) error {
_, err := h.runnerClient.Pause(c.Request().Context(), &pbjobrunner.Empty{})
if err != nil {
return h.error(c, http.StatusBadRequest, err, err.Error())
}
return c.NoContent(http.StatusAccepted)
}
func (h *Handler) ResumeJobHandler(c *echo.Context) error {
_, err := h.runnerClient.Resume(c.Request().Context(), &pbjobrunner.Empty{})
if err != nil {
return h.error(c, http.StatusBadRequest, err, err.Error())
}
return c.NoContent(http.StatusAccepted)
}
func (h *Handler) StopJobHandler(c *echo.Context) error {
_, err := h.runnerClient.Stop(c.Request().Context(), &pbjobrunner.Empty{})
if err != nil {
return h.error(c, http.StatusBadRequest, err, err.Error())
}
return c.NoContent(http.StatusAccepted)
}
func (h *Handler) StartJobHandler(c *echo.Context) error {
_, err := h.runnerClient.Start(c.Request().Context(), &pbjobrunner.Empty{})
if err != nil {
return h.error(c, http.StatusBadRequest, err, err.Error())
}
return c.NoContent(http.StatusAccepted)
}
func (h *Handler) SetOrderHandler(c *echo.Context) error {
var order []int32
if err := c.Bind(&order); err != nil {
return h.error(c, http.StatusBadRequest, err, "invalid JSON input")
}
_, err := h.runnerClient.SetOrder(c.Request().Context(), &pbjobrunner.SetOrderRequest{
GazetteIds: order,
})
if err != nil {
return h.error(c, http.StatusBadRequest, err, err.Error())
}
return c.NoContent(http.StatusAccepted)
}
func (h *Handler) SetPatentDeadlineHandler(c *echo.Context) error {
var deadline struct {
Days int32 `json:"d"`
Months int32 `json:"m"`
Years int32 `json:"y"`
}
if err := c.Bind(&deadline); err != nil {
return h.error(c, http.StatusBadRequest, err, "invalid JSON input")
}
if deadline.Days+deadline.Months+deadline.Years == 0 {
return h.error(c, http.StatusBadRequest, fmt.Errorf("no deadline fields set"), "invalid body: days, months or years property must be set")
}
_, err := h.runnerClient.SetPatentDeadline(c.Request().Context(), &pbjobrunner.SetPatentDeadlineRequest{
Days: deadline.Days,
Months: deadline.Months,
Years: deadline.Years,
})
if err != nil {
return h.error(c, http.StatusBadRequest, err, "failed request to job runner")
}
return c.NoContent(http.StatusAccepted)
}
func (h *Handler) validateGazetteIDParam(c *echo.Context) (model.Gazette, error) {
gazetteID := c.Param("gazette-id")
valID, err := strconv.Atoi(gazetteID)
if err != nil {
return model.Gazette{}, errors.New("invalid gazette id format")
}
gaz, err := h.db.GetGazette(c.Request().Context(), int32(valID))
if err != nil {
return model.Gazette{}, errors.New("gazette not found")
}
return gaz, nil
}
package handler
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"strconv"
"codeberg.org/at375/patent-flow/internal/model"
"codeberg.org/at375/patent-flow/internal/store"
"github.com/labstack/echo/v5"
)
func (h *Handler) workspaceIDParam(c *echo.Context) (int32, error) {
v, err := strconv.Atoi(c.Param("workspace-id"))
if err != nil {
return 0, errors.New("invalid workspace-id")
}
return int32(v), nil
}
func (h *Handler) nodeIDParam(c *echo.Context) (int64, error) {
v, err := strconv.ParseInt(c.Param("node-id"), 10, 64)
if err != nil {
return 0, errors.New("invalid node-id")
}
return v, nil
}
func (h *Handler) edgeIDParam(c *echo.Context) (int64, error) {
v, err := strconv.ParseInt(c.Param("edge-id"), 10, 64)
if err != nil {
return 0, errors.New("invalid edge-id")
}
return v, nil
}
func (h *Handler) assertWorkspaceOwner(c *echo.Context, wsID int32) (model.Workspace, error) {
userID, err := h.userIDFromJWT(c)
if err != nil {
return model.Workspace{}, fmt.Errorf("unauthorized")
}
ws, err := h.db.GetWorkspace(c.Request().Context(), wsID)
if err != nil {
return model.Workspace{}, fmt.Errorf("workspace not found")
}
if ws.UserID != userID {
return model.Workspace{}, fmt.Errorf("forbidden")
}
return ws, nil
}
func (h *Handler) ListWorkspacesHandler(c *echo.Context) error {
userID, err := h.userIDFromJWT(c)
if err != nil {
return c.NoContent(http.StatusUnauthorized)
}
workspaces, err := h.db.GetWorkspacesByUser(c.Request().Context(), userID)
if err != nil {
return h.error(c, http.StatusInternalServerError, err, "failed to list workspaces")
}
return c.JSON(http.StatusOK, workspaces)
}
func (h *Handler) CreateWorkspaceHandler(c *echo.Context) error {
userID, err := h.userIDFromJWT(c)
if err != nil {
return c.NoContent(http.StatusUnauthorized)
}
var req struct {
Name string `json:"name"`
}
if err := c.Bind(&req); err != nil || req.Name == "" {
req.Name = "Untitled"
}
count, err := h.db.CountWorkspacesByUser(c.Request().Context(), userID)
if err != nil {
return h.error(c, http.StatusInternalServerError, err, "failed to count workspaces")
}
maxWs := h.cfg.MaxWorkspacesPerUser
if count >= maxWs {
return h.error(c, http.StatusUnprocessableEntity,
fmt.Errorf("workspace limit reached"),
fmt.Sprintf("maximum of %d workspaces allowed", maxWs))
}
ws, err := h.db.CreateWorkspace(c.Request().Context(), userID, req.Name)
if err != nil {
return h.error(c, http.StatusInternalServerError, err, "failed to create workspace")
}
return c.JSON(http.StatusCreated, ws)
}
func (h *Handler) RenameWorkspaceHandler(c *echo.Context) error {
wsID, err := h.workspaceIDParam(c)
if err != nil {
return h.error(c, http.StatusBadRequest, err, err.Error())
}
if _, err := h.assertWorkspaceOwner(c, wsID); err != nil {
return h.error(c, http.StatusForbidden, err, err.Error())
}
var req struct {
Name string `json:"name"`
}
if err := c.Bind(&req); err != nil || req.Name == "" {
return h.error(c, http.StatusBadRequest, errors.New("name required"), "name is required")
}
ws, err := h.db.RenameWorkspace(c.Request().Context(), wsID, req.Name)
if err != nil {
return h.error(c, http.StatusInternalServerError, err, "failed to rename workspace")
}
return c.JSON(http.StatusOK, ws)
}
func (h *Handler) DeleteWorkspaceHandler(c *echo.Context) error {
wsID, err := h.workspaceIDParam(c)
if err != nil {
return h.error(c, http.StatusBadRequest, err, err.Error())
}
if _, err := h.assertWorkspaceOwner(c, wsID); err != nil {
return h.error(c, http.StatusForbidden, err, err.Error())
}
if err := h.db.DeleteWorkspace(c.Request().Context(), wsID); err != nil {
return h.error(c, http.StatusInternalServerError, err, "failed to delete workspace")
}
return c.NoContent(http.StatusNoContent)
}
func (h *Handler) GetWorkspaceGraphHandler(c *echo.Context) error {
wsID, err := h.workspaceIDParam(c)
if err != nil {
return h.error(c, http.StatusBadRequest, err, err.Error())
}
if _, err := h.assertWorkspaceOwner(c, wsID); err != nil {
return h.error(c, http.StatusForbidden, err, err.Error())
}
graph, err := h.db.GetWorkspaceGraph(c.Request().Context(), wsID)
if err != nil {
return h.error(c, http.StatusInternalServerError, err, "failed to load workspace graph")
}
return c.JSON(http.StatusOK, graph)
}
type AddNodeRequest struct {
NodeType model.NodeType `json:"node_type"`
EntityID int64 `json:"entity_id,omitempty"`
PosX int32 `json:"x"`
PosY int32 `json:"y"`
Width int32 `json:"w"`
Height int32 `json:"h"`
Content string `json:"content,omitempty"`
}
func (h *Handler) AddNodeHandler(c *echo.Context) error {
wsID, err := h.workspaceIDParam(c)
if err != nil {
return h.error(c, http.StatusBadRequest, err, err.Error())
}
if _, err := h.assertWorkspaceOwner(c, wsID); err != nil {
return h.error(c, http.StatusForbidden, err, err.Error())
}
var req AddNodeRequest
if err := c.Bind(&req); err != nil {
return h.error(c, http.StatusBadRequest, err, "invalid request body")
}
if err := validateAddNodeRequest(req); err != nil {
return h.error(c, http.StatusBadRequest, err, err.Error())
}
ctx := c.Request().Context()
switch req.NodeType {
case model.NodeTypeNote:
meta, _ := json.Marshal(model.NoteMetadata{Content: req.Content})
graph, err := h.db.AddWorkspaceNode(c.Request().Context(), model.CreateNodeParams{
WorkspaceID: wsID, NodeType: model.NodeTypeNote,
PosX: req.PosX, PosY: req.PosY, Width: req.Width, Height: req.Height,
Metadata: meta,
})
if err != nil {
return h.error(c, http.StatusInternalServerError, err, "failed to add note")
}
return c.JSON(http.StatusCreated, graph)
case model.NodeTypePatent:
graph, err := h.db.AddPatentNodeTx(ctx, store.AddPatentNodeParams{
WorkspaceID: wsID,
PatentID: req.EntityID,
PosX: req.PosX,
PosY: req.PosY,
Width: req.Width,
Height: req.Height,
})
if err != nil {
return h.error(c, http.StatusInternalServerError, err, "failed to add patent node")
}
return c.JSON(http.StatusCreated, graph)
case model.NodeTypeApplicant:
graph, err := h.db.AddApplicantNodeTx(ctx, store.AddApplicantNodeParams{
WorkspaceID: wsID,
ApplicantID: req.EntityID,
PosX: req.PosX,
PosY: req.PosY,
Width: req.Width,
Height: req.Height,
})
if err != nil {
return h.error(c, http.StatusInternalServerError, err, "failed to add applicant node")
}
return c.JSON(http.StatusCreated, graph)
case model.NodeTypeAgent:
graph, err := h.db.AddAgentNodeTx(ctx, store.AddAgentNodeParams{
WorkspaceID: wsID,
AgentID: req.EntityID,
PosX: req.PosX,
PosY: req.PosY,
Width: req.Width,
Height: req.Height,
})
if err != nil {
return h.error(c, http.StatusInternalServerError, err, "failed to add agent node")
}
return c.JSON(http.StatusCreated, graph)
default:
return h.error(c, http.StatusBadRequest, fmt.Errorf("unknown node type"), "unknown node type")
}
}
func (h *Handler) DeleteNodeHandler(c *echo.Context) error {
wsID, err := h.workspaceIDParam(c)
if err != nil {
return h.error(c, http.StatusBadRequest, err, err.Error())
}
if _, err := h.assertWorkspaceOwner(c, wsID); err != nil {
return h.error(c, http.StatusForbidden, err, err.Error())
}
nodeID, err := h.nodeIDParam(c)
if err != nil {
return h.error(c, http.StatusBadRequest, err, err.Error())
}
if err := h.db.DeleteWorkspaceNode(c.Request().Context(), nodeID); err != nil {
return h.error(c, http.StatusInternalServerError, err, "failed to delete node")
}
return c.NoContent(http.StatusNoContent)
}
func (h *Handler) UpdateNodePositionHandler(c *echo.Context) error {
wsID, err := h.workspaceIDParam(c)
if err != nil {
return h.error(c, http.StatusBadRequest, err, err.Error())
}
if _, err := h.assertWorkspaceOwner(c, wsID); err != nil {
return h.error(c, http.StatusForbidden, err, err.Error())
}
nodeID, err := h.nodeIDParam(c)
if err != nil {
return h.error(c, http.StatusBadRequest, err, err.Error())
}
var req struct {
PosX int32 `json:"x"`
PosY int32 `json:"y"`
Width int32 `json:"w"`
Height int32 `json:"h"`
}
if err := c.Bind(&req); err != nil {
return h.error(c, http.StatusBadRequest, err, "invalid body")
}
if err := h.db.UpdateWorkspaceNodeBounds(c.Request().Context(), model.UpdateNodeBoundsParams{
NodeID: nodeID,
PosX: req.PosX, PosY: req.PosY,
Width: req.Width, Height: req.Height,
}); err != nil {
return h.error(c, http.StatusInternalServerError, err, "failed to update bounds")
}
return c.NoContent(http.StatusNoContent)
}
func (h *Handler) UpdateNoteContentHandler(c *echo.Context) error {
wsID, err := h.workspaceIDParam(c)
if err != nil {
return h.error(c, http.StatusBadRequest, err, err.Error())
}
if _, err := h.assertWorkspaceOwner(c, wsID); err != nil {
return h.error(c, http.StatusForbidden, err, err.Error())
}
nodeID, err := h.nodeIDParam(c)
if err != nil {
return h.error(c, http.StatusBadRequest, err, err.Error())
}
var req struct {
Content string `json:"content"`
}
if err := c.Bind(&req); err != nil {
return h.error(c, http.StatusBadRequest, err, "invalid body")
}
// unfailable marshal
merged, _ := json.Marshal(map[string]any{"content": req.Content})
if err := h.db.UpdateWorkspaceNodeMetadata(c.Request().Context(), nodeID, merged); err != nil {
return h.error(c, http.StatusInternalServerError, err, "failed to update node")
}
return c.NoContent(http.StatusNoContent)
}
func (h *Handler) MoveNodeHandler(c *echo.Context) error {
wsID, err := h.workspaceIDParam(c)
if err != nil {
return h.error(c, http.StatusBadRequest, err, err.Error())
}
if _, err := h.assertWorkspaceOwner(c, wsID); err != nil {
return h.error(c, http.StatusForbidden, err, err.Error())
}
nodeID, err := h.nodeIDParam(c)
if err != nil {
return h.error(c, http.StatusBadRequest, err, err.Error())
}
var req struct {
TargetWorkspaceID int32 `json:"target_workspace_id"`
}
if err := c.Bind(&req); err != nil || req.TargetWorkspaceID == 0 {
return h.error(c, http.StatusBadRequest, errors.New("target_workspace_id required"), "target_workspace_id required")
}
if req.TargetWorkspaceID == wsID {
return h.error(c, http.StatusBadRequest, errors.New("target workspace must be different"), "target workspace must be different")
}
if _, err := h.assertWorkspaceOwner(c, req.TargetWorkspaceID); err != nil {
return h.error(c, http.StatusForbidden, err, "you do not own the target workspace")
}
if err := h.db.MoveWorkspaceNode(c.Request().Context(), nodeID, req.TargetWorkspaceID); err != nil {
return h.error(c, http.StatusInternalServerError, err, "failed to move node; unique node may already exist in target workspace")
}
return c.NoContent(http.StatusNoContent)
}
func (h *Handler) CopyNodeHandler(c *echo.Context) error {
wsID, err := h.workspaceIDParam(c)
if err != nil {
return h.error(c, http.StatusBadRequest, err, err.Error())
}
if _, err := h.assertWorkspaceOwner(c, wsID); err != nil {
return h.error(c, http.StatusForbidden, err, err.Error())
}
nodeID, err := h.nodeIDParam(c)
if err != nil {
return h.error(c, http.StatusBadRequest, err, err.Error())
}
var req struct {
TargetWorkspaceID int32 `json:"target_workspace_id"`
}
if err := c.Bind(&req); err != nil || req.TargetWorkspaceID == 0 {
return h.error(c, http.StatusBadRequest, errors.New("target_workspace_id required"), "target_workspace_id required")
}
if _, err := h.assertWorkspaceOwner(c, req.TargetWorkspaceID); err != nil {
return h.error(c, http.StatusForbidden, err, "you do not own the target workspace")
}
graph, err := h.db.CopyWorkspaceNode(c.Request().Context(), nodeID, req.TargetWorkspaceID)
if err != nil {
h.error(c, http.StatusInternalServerError, err, "failed to create copy")
}
return c.JSON(http.StatusCreated, graph)
}
func (h *Handler) RefreshNodeMetadataHandler(c *echo.Context) error {
wsID, err := h.workspaceIDParam(c)
if err != nil {
return h.error(c, http.StatusBadRequest, err, err.Error())
}
if _, err := h.assertWorkspaceOwner(c, wsID); err != nil {
return h.error(c, http.StatusForbidden, err, err.Error())
}
nodeID, err := h.nodeIDParam(c)
if err != nil {
return h.error(c, http.StatusBadRequest, err, err.Error())
}
node, err := h.db.RefreshNodeMetadata(c.Request().Context(), nodeID)
if err != nil {
return h.error(c, http.StatusInternalServerError, err, "failed to refresh node metadata")
}
return c.JSON(http.StatusOK, node)
}
func (h *Handler) AddEdgeHandler(c *echo.Context) error {
wsID, err := h.workspaceIDParam(c)
if err != nil {
return h.error(c, http.StatusBadRequest, err, err.Error())
}
if _, err := h.assertWorkspaceOwner(c, wsID); err != nil {
return h.error(c, http.StatusForbidden, err, err.Error())
}
var req struct {
NodeID1 int64 `json:"node_id_1"`
NodeID2 int64 `json:"node_id_2"`
}
if err := c.Bind(&req); err != nil || req.NodeID1 == 0 || req.NodeID2 == 0 {
return h.error(c, http.StatusBadRequest, errors.New("node_id_1 and node_id_2 required"), "2 nodes required")
}
edge, err := h.db.AddWorkspaceEdge(c.Request().Context(), wsID, req.NodeID1, req.NodeID2, model.EdgeTypeCustom)
if err != nil {
return h.error(c, http.StatusInternalServerError, err, "failed to add edge")
}
return c.JSON(http.StatusCreated, edge)
}
func (h *Handler) DeleteEdgeHandler(c *echo.Context) error {
wsID, err := h.workspaceIDParam(c)
if err != nil {
return h.error(c, http.StatusBadRequest, err, err.Error())
}
if _, err := h.assertWorkspaceOwner(c, wsID); err != nil {
return h.error(c, http.StatusForbidden, err, err.Error())
}
edgeID, err := h.edgeIDParam(c)
if err != nil {
return h.error(c, http.StatusBadRequest, err, err.Error())
}
if err := h.db.DeleteWorkspaceEdge(c.Request().Context(), edgeID); err != nil {
return h.error(c, http.StatusInternalServerError, err, "failed to delete edge")
}
return c.NoContent(http.StatusNoContent)
}
func validateAddNodeRequest(req AddNodeRequest) error {
switch req.NodeType {
case model.NodeTypePatent, model.NodeTypeApplicant, model.NodeTypeAgent:
if req.EntityID == 0 {
return fmt.Errorf("entity_id is required for node_type %q", req.NodeType)
}
case model.NodeTypeNote:
if req.EntityID != 0 {
return errors.New("entity_id must be 0 for note nodes")
}
default:
return fmt.Errorf("unknown node_type %q", req.NodeType)
}
return nil
}
func (h *Handler) BulkDeleteNodesHandler(c *echo.Context) error {
wsID, err := h.workspaceIDParam(c)
if err != nil {
return h.error(c, http.StatusBadRequest, err, err.Error())
}
if _, err := h.assertWorkspaceOwner(c, wsID); err != nil {
return h.error(c, http.StatusForbidden, err, err.Error())
}
var req struct {
NodeIDs []int64 `json:"node_ids"`
}
if err := c.Bind(&req); err != nil || len(req.NodeIDs) == 0 {
return h.error(c, http.StatusBadRequest, errors.New("node_ids required"), "node_ids required")
}
if err := h.db.BulkDeleteNodes(c.Request().Context(), wsID, req.NodeIDs); err != nil {
return h.error(c, http.StatusInternalServerError, err, "failed to delete nodes")
}
return c.NoContent(http.StatusNoContent)
}
func (h *Handler) BulkMoveNodesHandler(c *echo.Context) error {
wsID, err := h.workspaceIDParam(c)
if err != nil {
return h.error(c, http.StatusBadRequest, err, err.Error())
}
if _, err := h.assertWorkspaceOwner(c, wsID); err != nil {
return h.error(c, http.StatusForbidden, err, err.Error())
}
var req struct {
NodeIDs []int64 `json:"node_ids"`
TargetWorkspaceID int32 `json:"target_workspace_id"`
}
if err := c.Bind(&req); err != nil || len(req.NodeIDs) == 0 || req.TargetWorkspaceID == 0 {
return h.error(c, http.StatusBadRequest,
errors.New("node_ids and target_workspace_id required"),
"node_ids and target_workspace_id required")
}
if req.TargetWorkspaceID == wsID {
return h.error(c, http.StatusBadRequest, errors.New("target workspace must be different"), "target workspace must be different")
}
if _, err := h.assertWorkspaceOwner(c, req.TargetWorkspaceID); err != nil {
return h.error(c, http.StatusForbidden, err, "you do not own the target workspace")
}
if err := h.db.BulkMoveNodes(c.Request().Context(), wsID, req.NodeIDs, req.TargetWorkspaceID); err != nil {
return h.error(c, http.StatusInternalServerError, err, "failed to move nodes")
}
return c.NoContent(http.StatusNoContent)
}
func (h *Handler) BulkCopyNodesHandler(c *echo.Context) error {
wsID, err := h.workspaceIDParam(c)
if err != nil {
return h.error(c, http.StatusBadRequest, err, err.Error())
}
if _, err := h.assertWorkspaceOwner(c, wsID); err != nil {
return h.error(c, http.StatusForbidden, err, err.Error())
}
var req struct {
NodeIDs []int64 `json:"node_ids"`
TargetWorkspaceID int32 `json:"target_workspace_id"`
}
if err := c.Bind(&req); err != nil || len(req.NodeIDs) == 0 || req.TargetWorkspaceID == 0 {
return h.error(c, http.StatusBadRequest,
errors.New("node_ids and target_workspace_id required"),
"node_ids and target_workspace_id required")
}
if _, err := h.assertWorkspaceOwner(c, req.TargetWorkspaceID); err != nil {
return h.error(c, http.StatusForbidden, err, "you do not own the target workspace")
}
graph, err := h.db.BulkCopyNodes(c.Request().Context(), wsID, req.NodeIDs, req.TargetWorkspaceID)
if err != nil {
return h.error(c, http.StatusInternalServerError, err, "failed to copy nodes")
}
return c.JSON(http.StatusCreated, graph)
}
func (h *Handler) BulkUpdateNodePositionsHandler(c *echo.Context) error {
wsID, err := h.workspaceIDParam(c)
if err != nil {
return h.error(c, http.StatusBadRequest, err, err.Error())
}
if _, err := h.assertWorkspaceOwner(c, wsID); err != nil {
return h.error(c, http.StatusForbidden, err, err.Error())
}
var req struct {
Nodes []struct {
NodeID int64 `json:"node_id"`
PosX int32 `json:"x"`
PosY int32 `json:"y"`
Width int32 `json:"w"`
Height int32 `json:"h"`
} `json:"nodes"`
}
if err := c.Bind(&req); err != nil || len(req.Nodes) == 0 {
return h.error(c, http.StatusBadRequest, errors.New("nodes required"), "nodes required")
}
updates := make([]model.UpdateNodeBoundsParams, len(req.Nodes))
for i, n := range req.Nodes {
updates[i] = model.UpdateNodeBoundsParams{
NodeID: n.NodeID,
PosX: n.PosX,
PosY: n.PosY,
Width: n.Width,
Height: n.Height,
}
}
if err := h.db.BulkUpdateNodePositions(c.Request().Context(), wsID, updates); err != nil {
return h.error(c, http.StatusInternalServerError, err, "failed to update positions")
}
return c.NoContent(http.StatusNoContent)
}
package metrics
import (
grpc_prometheus "github.com/grpc-ecosystem/go-grpc-prometheus"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/collectors"
)
var (
HttpRequestsTotal = prometheus.NewCounterVec(
prometheus.CounterOpts{
Name: "web_back_http_requests_total",
Help: "Total number of HTTP requests",
},
[]string{"method", "path", "status"},
)
HttpRequestDuration = prometheus.NewHistogramVec(
prometheus.HistogramOpts{
Name: "web_back_http_request_duration_seconds",
Help: "HTTP request latency",
Buckets: prometheus.DefBuckets,
},
[]string{"method", "path", "status"},
)
HttpRequestsInFlight = prometheus.NewGauge(prometheus.GaugeOpts{
Name: "web_back_http_requests_in_flight",
Help: "Current number of in-flight HTTP requests",
})
LoggerFailed = prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "web_back_logger_events_failed_total",
Help: "Log events failed to be logged due to sink errors",
}, []string{"sink"})
LoggerDropped = prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "web_back_logger_events_dropped_total",
Help: "Log events dropped due to full channels",
}, []string{"sink"})
LoggerFallbackFailed = prometheus.NewCounter(prometheus.CounterOpts{
Name: "web_back_logger_events_fallback_failed_total",
Help: "Log events failed to be logged due to fallback errors",
})
)
func Register(reg prometheus.Registerer) {
grpc_prometheus.EnableClientHandlingTimeHistogram()
reg.MustRegister(
HttpRequestsTotal,
HttpRequestDuration,
HttpRequestsInFlight,
grpc_prometheus.DefaultClientMetrics,
LoggerFailed,
LoggerDropped,
LoggerFallbackFailed,
collectors.NewGoCollector(),
collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}),
)
}
package metrics
import (
"strconv"
"time"
"github.com/labstack/echo/v5"
)
func Middleware() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c *echo.Context) error {
HttpRequestsInFlight.Inc()
defer HttpRequestsInFlight.Dec()
start := time.Now()
err := next(c)
duration := time.Since(start).Seconds()
path := c.Path()
if path == "" {
path = "unknown"
}
res := c.Response()
_, status := echo.ResolveResponseStatus(res, err)
statusStr := strconv.Itoa(status)
HttpRequestsTotal.WithLabelValues(c.Request().Method, path, statusStr).Inc()
HttpRequestDuration.WithLabelValues(c.Request().Method, path, statusStr).Observe(duration)
return err
}
}
}
package model
import (
"time"
)
type Patent struct {
ID int64 `json:"id"`
Name string `json:"name"`
Number string `json:"number"`
PubDate time.Time `json:"pub_date"`
PriorityDate time.Time `json:"priority_date"`
Countries []string `json:"countries"`
ApplicantID int64 `json:"applicant_id"`
GazetteID int32 `json:"gazette_id"`
DeadlineDate time.Time `json:"deadline_date"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type PatentWithRelations struct {
Patent Patent `json:"patent"`
Applicant Applicant `json:"applicant"`
Agents []Agent `json:"agents"`
}
// TODO primitive now, implement deadline configuration by client
func (p Patent) CalculateDeadlineDate() time.Time {
return p.PriorityDate.AddDate(0, 33, 0)
}
package server
import (
"codeberg.org/at375/patent-flow/internal/handler"
echojwt "github.com/labstack/echo-jwt/v5"
"github.com/labstack/echo/v5"
)
func RegisterRoutes(e *echo.Echo, h handler.PFHandler) {
l := e.Group("/api/login")
l.POST("", h.LoginHandler)
a := e.Group("/api")
a.Use(echojwt.WithConfig(echojwt.Config{
SigningKey: h.JWTSecret(),
TokenLookup: "cookie:pfjwttoken",
ErrorHandler: func(c *echo.Context, err error) error {
return c.JSON(401, map[string]string{
"error": "Unauthorized",
})
},
}))
admin := a.Group("")
admin.Use(h.AdminOnlyMiddleware())
a.GET("/me", h.MeHandler)
admin.POST("/gazettes/:year", h.UpdateGazetteByYearHandler)
a.GET("/gazettes/:year", h.GetGazetteByYearHandler)
a.GET("/gazette/:gazette-id", h.GetGazetteHandler)
a.GET("/patents", h.ListPatentsHandler)
a.GET("/applicants", h.ListApplicantsHandler)
a.GET("/agents", h.ListAgentsHandler)
a.GET("/patents/:id", h.GetPatentHandler)
a.PATCH("/patents/:id/deadline", h.UpdatePatentDeadlineDateHandler)
a.GET("/applicants/:id", h.GetApplicantHandler)
a.GET("/agents/:id", h.GetAgentHandler)
a.PATCH("/agents/:id/email", h.UpdateAgentEmailHandler)
a.DELETE("/agents/:id/email", h.ResetAgentEmailHandler)
admin.POST("/patent-urls/:gazette-id", h.UpdatePatentUrlsByGazetteHandler)
a.GET("/patent-urls/:gazette-id", h.GetPatentUrlsByGazetteHandler)
a.GET("/patent-urls/:gazette-id/count", h.GetPatentUrlsCountByGazetteHandler)
admin.POST("/jobs/add/:gazette-id", h.AddJobHandler)
admin.POST("/jobs/delete/:gazette-id", h.DeleteJobHandler)
admin.POST("/jobs/pause", h.PauseJobHandler)
admin.POST("/jobs/resume", h.ResumeJobHandler)
admin.POST("/jobs/stop", h.StopJobHandler)
admin.POST("/jobs/start", h.StartJobHandler)
admin.POST("/jobs/reorder", h.SetOrderHandler)
admin.POST("/jobs/deadline", h.SetPatentDeadlineHandler)
a.GET("/workspaces", h.ListWorkspacesHandler)
a.GET("/workspaces/:workspace-id/graph", h.GetWorkspaceGraphHandler)
a.PATCH("/workspaces/:workspace-id", h.RenameWorkspaceHandler)
a.POST("/workspaces", h.CreateWorkspaceHandler)
a.DELETE("/workspaces/:workspace-id", h.DeleteWorkspaceHandler)
a.POST("/workspaces/:workspace-id/nodes/add", h.AddNodeHandler)
a.POST("/workspaces/:workspace-id/nodes/:node-id/copy", h.CopyNodeHandler)
a.POST("/workspaces/:workspace-id/nodes/:node-id/move", h.MoveNodeHandler)
a.PATCH("/workspaces/:workspace-id/nodes/:node-id/position", h.UpdateNodePositionHandler)
a.PATCH("/workspaces/:workspace-id/nodes/:node-id/content", h.UpdateNoteContentHandler)
a.DELETE("/workspaces/:workspace-id/nodes/:node-id", h.DeleteNodeHandler)
a.POST("/workspaces/:workspace-id/nodes/:node-id/refresh-meta", h.RefreshNodeMetadataHandler)
a.POST("/workspaces/:workspace-id/edges/add", h.AddEdgeHandler)
a.DELETE("/workspaces/:workspace-id/edges/:edge-id", h.DeleteEdgeHandler)
a.DELETE("/workspaces/:workspace-id/nodes/bulk", h.BulkDeleteNodesHandler)
a.POST("/workspaces/:workspace-id/nodes/bulk/move", h.BulkMoveNodesHandler)
a.POST("/workspaces/:workspace-id/nodes/bulk/copy", h.BulkCopyNodesHandler)
a.PATCH("/workspaces/:workspace-id/nodes/bulk/position", h.BulkUpdateNodePositionsHandler)
}
package server
import (
"fmt"
"net/http"
"codeberg.org/at375/patent-flow/internal/handler"
"codeberg.org/at375/patent-flow/internal/metrics"
"codeberg.org/at375/patent-flow/logger"
"github.com/labstack/echo/v5"
"github.com/labstack/echo/v5/middleware"
)
func NewServer(h handler.PFHandler, l logger.Logger) *echo.Echo {
e := echo.New()
// top -> bottom : outer -> inner
e.Use(metrics.Middleware())
e.Use(middleware.RequestLoggerWithConfig(middleware.RequestLoggerConfig{
LogLatency: true,
LogRemoteIP: true,
LogHost: true,
LogMethod: true,
LogURI: true,
LogRequestID: true,
LogUserAgent: true,
LogStatus: true,
LogContentLength: true,
LogResponseSize: true,
HandleError: true,
LogValuesFunc: func(c *echo.Context, v middleware.RequestLoggerValues) error {
ctx := c.Request().Context()
fields := map[string]any{
"method": v.Method,
"uri": v.URI,
"status": v.Status,
"latency_ms": v.Latency.Milliseconds(),
"host": v.Host,
"bytes_in": v.ContentLength,
"bytes_out": v.ResponseSize,
"user_agent": v.UserAgent,
"remote_ip": v.RemoteIP,
"request_id": v.RequestID,
}
msg := fmt.Sprintf("%s %s %d", v.Method, v.URI, v.Status)
if v.Error != nil {
fields["error"] = v.Error.Error()
}
switch {
case v.Status >= 500:
l.Error(ctx, msg, fields)
case v.Status >= 400:
l.Warning(ctx, msg, fields)
default:
l.Info(ctx, msg, fields)
}
return nil
},
}))
e.Use(middleware.Recover())
e.Use(middleware.CSRFWithConfig(middleware.CSRFConfig{
Skipper: middleware.DefaultSkipper,
TokenLength: 32,
TokenLookup: "header:" + echo.HeaderXCSRFToken,
ContextKey: "csrf",
CookieName: "_csrf",
CookiePath: "/",
CookieSecure: true,
CookieMaxAge: 86400,
CookieHTTPOnly: false,
CookieSameSite: http.SameSiteStrictMode,
}))
RegisterRoutes(e, h)
return e
}
package store
import (
"context"
"codeberg.org/at375/patent-flow/gen/sqlcgen"
"codeberg.org/at375/patent-flow/internal/model"
sq "github.com/Masterminds/squirrel"
)
func (s *PFStore) GetAgent(ctx context.Context, id int64) (model.Agent, error) {
agent, err := s.roqueries.GetAgent(ctx, id)
if err != nil {
return model.Agent{}, s.error(ctx, err, "failed to get agent", map[string]any{
"agent_id": id,
})
}
return model.Agent(agent), nil
}
var AllowedAgentSorts = map[string]string{
"id": "agents.id",
"name": "agents.name",
"email": "agents.email",
"address": "agents.address",
"postcode": "agents.postcode",
"country": "agents.country",
"created_at": "agents.created_at",
"updated_at": "agents.updated_at",
}
var AllowedAgentFilters = []string{
"country",
"patent_id",
}
func (s *PFStore) UpdateAgentEmail(ctx context.Context, id int64, email string) error {
err := s.rwqueries.UpdateAgentEmail(ctx, sqlcgen.UpdateAgentEmailParams{
ID: id,
Email: email,
})
if err != nil {
return s.error(ctx, err, "UpdateAgentEmail", map[string]any{
"agent_id": id,
})
}
return nil
}
func (s *PFStore) ListAgents(ctx context.Context, p ListParams) (Page[model.Agent], error) {
q := psql.
Select(
"agents.id",
"agents.name",
"agents.email",
"agents.address",
"agents.postcode",
"agents.country",
"agents.created_at",
"agents.updated_at",
"COUNT(*) OVER() AS total",
).
From("agents")
if v, ok := p.Filters["country"]; ok {
q = q.Where(sq.Eq{"agents.country": v})
}
if v, ok := p.Filters["patent_id"]; ok {
q = q.Where(
sq.Expr(
"EXISTS (SELECT 1 FROM patent_agents pa WHERE pa.agent_id = agents.id AND pa.patent_id = ?)",
v,
),
)
}
if p.Search != "" {
pat := searchPattern(p.Search)
q = q.Where(sq.Or{
ilike("agents.name", pat),
ilike("agents.email", pat),
ilike("agents.address", pat),
ilike("agents.postcode", pat),
ilike("agents.country", pat),
})
}
q = applyListParams(q, p)
query, args, err := q.ToSql()
if err != nil {
return Page[model.Agent]{}, s.error(ctx, err, "ListAgents: build sql", nil)
}
rows, err := s.ropool.Query(ctx, query, args...)
if err != nil {
return Page[model.Agent]{}, s.error(ctx, err, "ListAgents: query", nil)
}
defer rows.Close()
var (
items []model.Agent
total int
)
for rows.Next() {
var r model.Agent
if err := rows.Scan(
&r.ID,
&r.Name,
&r.Email,
&r.Address,
&r.Postcode,
&r.Country,
&r.CreatedAt,
&r.UpdatedAt,
&total,
); err != nil {
return Page[model.Agent]{}, s.error(ctx, err, "ListAgents: scan", nil)
}
items = append(items, r)
}
if err := rows.Err(); err != nil {
return Page[model.Agent]{}, s.error(ctx, err, "ListAgents: iterate", nil)
}
return newPage(items, total, p.Page, p.PageSize), nil
}
package store
import (
"context"
"codeberg.org/at375/patent-flow/internal/model"
sq "github.com/Masterminds/squirrel"
)
func (s *PFStore) GetApplicant(ctx context.Context, id int64) (model.Applicant, error) {
applicant, err := s.roqueries.GetApplicant(ctx, id)
if err != nil {
return model.Applicant{}, s.error(ctx, err, "failed to get applicant", map[string]any{
"applicant_id": id,
})
}
return model.Applicant(applicant), nil
}
var AllowedApplicantSorts = map[string]string{
"id": "applicants.id",
"name": "applicants.name",
"address": "applicants.address",
"postcode": "applicants.postcode",
"country": "applicants.country",
"created_at": "applicants.created_at",
"updated_at": "applicants.updated_at",
}
var AllowedApplicantFilters = []string{
"country",
}
func (s *PFStore) ListApplicants(ctx context.Context, p ListParams) (Page[model.Applicant], error) {
q := psql.
Select(
"id",
"name",
"address",
"postcode",
"country",
"created_at",
"updated_at",
"COUNT(*) OVER() AS total",
).
From("applicants")
if v, ok := p.Filters["country"]; ok {
q = q.Where(sq.Eq{"country": v})
}
if p.Search != "" {
pat := searchPattern(p.Search)
q = q.Where(sq.Or{
ilike("name", pat),
ilike("address", pat),
ilike("postcode", pat),
ilike("country", pat),
})
}
q = applyListParams(q, p)
query, args, err := q.ToSql()
if err != nil {
return Page[model.Applicant]{}, s.error(ctx, err, "ListApplicants: build sql", nil)
}
rows, err := s.ropool.Query(ctx, query, args...)
if err != nil {
return Page[model.Applicant]{}, s.error(ctx, err, "ListApplicants: query", nil)
}
defer rows.Close()
var (
items []model.Applicant
total int
)
for rows.Next() {
var r model.Applicant
if err := rows.Scan(
&r.ID,
&r.Name,
&r.Address,
&r.Postcode,
&r.Country,
&r.CreatedAt,
&r.UpdatedAt,
&total,
); err != nil {
return Page[model.Applicant]{}, s.error(ctx, err, "ListApplicants: scan", nil)
}
items = append(items, r)
}
if err := rows.Err(); err != nil {
return Page[model.Applicant]{}, s.error(ctx, err, "ListApplicants: iterate", nil)
}
return newPage(items, total, p.Page, p.PageSize), nil
}
package store
import (
"context"
"encoding/json"
"fmt"
"hash/fnv"
"time"
"codeberg.org/at375/patent-flow/common/types"
"codeberg.org/at375/patent-flow/internal/model"
"codeberg.org/at375/patent-flow/internal/util/paturlhelper"
"codeberg.org/at375/patent-flow/logger"
"github.com/go-redis/redis"
)
const (
ttlPatent = 24 * time.Hour
ttlRelations = 1 * time.Hour // entity pages that display related patents
ttlList = 1 * time.Hour
ttlGazette = (24 * time.Hour) * 7
)
// var defaultPatListParams ListParams = ListParams{
// Page: 1,
// PageSize: 17,
// Sort: []SortField{SortField{Column: "deadline_date", Dir: "desc"}},
// }
type cachePrefix string
const (
prefixPatent cachePrefix = "patent"
prefixPatentFull cachePrefix = "patent:full"
prefixAgent cachePrefix = "agent"
prefixApplicant cachePrefix = "applicant"
prefixPatentList cachePrefix = "patents:list"
prefixAgentList cachePrefix = "agents:list"
prefixApplicantList cachePrefix = "applicants:list"
prefixGazettesByYear cachePrefix = "gazettes:year"
prefixGazette cachePrefix = "gazette"
)
type CacheRefreshKey struct{}
func (p cachePrefix) key(id any) string {
return fmt.Sprintf("%s:%v", p, id)
}
type CachedStore struct {
db Store
cache *redis.Client
logger logger.Logger
}
func NewCachedStore(db Store, cache *redis.Client, logger logger.Logger) *CachedStore {
return &CachedStore{
db: db,
cache: cache,
logger: logger,
}
}
func getFromCache[T any](c *redis.Client, key string) (T, bool) {
var zero T
data, err := c.Get(key).Bytes()
if err != nil {
return zero, false
}
var result T
if err := json.Unmarshal(data, &result); err != nil {
return zero, false
}
return result, true
}
func setInCache[T any](c *redis.Client, key string, value T, ttl time.Duration) error {
data, err := json.Marshal(value)
if err != nil {
return err
}
return c.Set(key, data, ttl).Err()
}
// Determine list queries stable enough to cache:
// no search term, max 1 sort, max 2 filters
func isCacheable(p ListParams) bool {
return p.Search == "" && len(p.Sort) <= 1 && len(p.Filters) <= 2
}
func hashListParams(p ListParams) string {
b, _ := json.Marshal(p)
h := fnv.New64a()
h.Write(b)
return fmt.Sprintf("%x", h.Sum64())
}
func shouldRefresh(ctx context.Context) bool {
v, _ := ctx.Value(CacheRefreshKey{}).(bool)
return v
}
func (cs *CachedStore) GetPatent(ctx context.Context, id int64) (model.Patent, error) {
key := prefixPatent.key(id)
if !shouldRefresh(ctx) {
if cached, ok := getFromCache[model.Patent](cs.cache, key); ok {
return cached, nil
}
}
patent, err := cs.db.GetPatent(ctx, id)
if err != nil {
return model.Patent{}, err
}
if err := setInCache(cs.cache, key, patent, ttlPatent); err != nil {
cs.logger.Warning(ctx, "failed to cache patent", map[string]any{"id": id, "error": err.Error()})
}
return patent, nil
}
func (cs *CachedStore) GetPatentWithRelations(ctx context.Context, patentID int64) (model.PatentWithRelations, error) {
key := prefixPatentFull.key(patentID)
if !shouldRefresh(ctx) {
if cached, ok := getFromCache[model.PatentWithRelations](cs.cache, key); ok {
return cached, nil
}
}
patent, err := cs.db.GetPatentWithRelations(ctx, patentID)
if err != nil {
return model.PatentWithRelations{}, err
}
if err := setInCache(cs.cache, key, patent, ttlPatent); err != nil {
cs.logger.Warning(ctx, "failed to cache patent with relations", map[string]any{"id": patentID, "error": err.Error()})
}
return patent, nil
}
func (cs *CachedStore) ListPatents(ctx context.Context, p ListParams) (Page[PatentListItem], error) {
// figure out optimal list cahing strat
return cs.db.ListPatents(ctx, p)
// if !isCacheable(p) {
// return cs.db.ListPatents(ctx, p)
// }
// key := prefixPatentList.key(hashListParams(p))
// if !shouldRefresh(ctx) {
// if cached, ok := getFromCache[Page[PatentListItem]](cs.cache, key); ok {
// return cached, nil
// }
// }
// page, err := cs.db.ListPatents(ctx, p)
// if err != nil {
// return Page[PatentListItem]{}, err
// }
// if err := setInCache(cs.cache, key, page, ttlList); err != nil {
// cs.logger.Warning(ctx, "failed to cache patent list", map[string]any{"error": err.Error()})
// }
// return page, nil
}
func (cs *CachedStore) GetPatentsByGazette(ctx context.Context, gazetteID int32) ([]model.Patent, error) {
return cs.db.GetPatentsByGazette(ctx, gazetteID)
}
func (cs *CachedStore) GetApplicant(ctx context.Context, id int64) (model.Applicant, error) {
key := prefixApplicant.key(id)
if !shouldRefresh(ctx) {
if cached, ok := getFromCache[model.Applicant](cs.cache, key); ok {
return cached, nil
}
}
applicant, err := cs.db.GetApplicant(ctx, id)
if err != nil {
return model.Applicant{}, err
}
if err := setInCache(cs.cache, key, applicant, ttlRelations); err != nil {
cs.logger.Warning(ctx, "failed to cache applicant", map[string]any{"id": id, "error": err.Error()})
}
return applicant, nil
}
func (cs *CachedStore) ListApplicants(ctx context.Context, p ListParams) (Page[model.Applicant], error) {
if !isCacheable(p) {
return cs.db.ListApplicants(ctx, p)
}
key := prefixApplicantList.key(hashListParams(p))
if !shouldRefresh(ctx) {
if cached, ok := getFromCache[Page[model.Applicant]](cs.cache, key); ok {
return cached, nil
}
}
page, err := cs.db.ListApplicants(ctx, p)
if err != nil {
return Page[model.Applicant]{}, err
}
if err := setInCache(cs.cache, key, page, ttlList); err != nil {
cs.logger.Warning(ctx, "failed to cache applicant list", map[string]any{"error": err.Error()})
}
return page, nil
}
func (cs *CachedStore) GetAgent(ctx context.Context, id int64) (model.Agent, error) {
key := prefixAgent.key(id)
if !shouldRefresh(ctx) {
if cached, ok := getFromCache[model.Agent](cs.cache, key); ok {
return cached, nil
}
}
agent, err := cs.db.GetAgent(ctx, id)
if err != nil {
return model.Agent{}, err
}
if err := setInCache(cs.cache, key, agent, ttlRelations); err != nil {
cs.logger.Warning(ctx, "failed to cache agent", map[string]any{"id": id, "error": err.Error()})
}
return agent, nil
}
func (cs *CachedStore) ListAgents(ctx context.Context, p ListParams) (Page[model.Agent], error) {
// figure out optimal list cahing strat
return cs.db.ListAgents(ctx, p)
// if !isCacheable(p) {
// return cs.db.ListAgents(ctx, p)
// }
// key := prefixAgentList.key(hashListParams(p))
// if !shouldRefresh(ctx) {
// if cached, ok := getFromCache[Page[model.Agent]](cs.cache, key); ok {
// return cached, nil
// }
// }
// page, err := cs.db.ListAgents(ctx, p)
// if err != nil {
// return Page[model.Agent]{}, err
// }
// if err := setInCache(cs.cache, key, page, ttlList); err != nil {
// cs.logger.Warning(ctx, "failed to cache agent list", map[string]any{"error": err.Error()})
// }
// return page, nil
}
func (cs *CachedStore) CreateOrGetGazette(ctx context.Context, year, name, date, count string) (model.Gazette, error) {
gazette, err := cs.db.CreateOrGetGazette(ctx, year, name, date, count)
if err != nil {
return model.Gazette{}, err
}
if err := cs.cache.Del(prefixGazettesByYear.key(year)).Err(); err != nil {
cs.logger.Warning(ctx, "failed to invalidate gazettes cache", map[string]any{"year": year, "error": err.Error()})
}
return gazette, nil
}
func (cs *CachedStore) GetGazette(ctx context.Context, id int32) (model.Gazette, error) {
key := prefixGazette.key(id)
if !shouldRefresh(ctx) {
if cached, ok := getFromCache[model.Gazette](cs.cache, key); ok {
return cached, nil
}
}
gaz, err := cs.db.GetGazette(ctx, id)
if err != nil {
return model.Gazette{}, err
}
if err := setInCache(cs.cache, key, gaz, ttlGazette); err != nil {
cs.logger.Warning(ctx, "failed to cache agent", map[string]any{"id": id, "error": err.Error()})
}
return gaz, nil
}
func (cs *CachedStore) GetGazettesByYear(ctx context.Context, year string) ([]model.Gazette, error) {
key := prefixGazettesByYear.key(year)
if !shouldRefresh(ctx) {
if cached, ok := getFromCache[[]model.Gazette](cs.cache, key); ok {
return cached, nil
}
}
gazettes, err := cs.db.GetGazettesByYear(ctx, year)
if err != nil {
return nil, err
}
if err := setInCache(cs.cache, key, gazettes, ttlList); err != nil {
cs.logger.Warning(ctx, "failed to cache gazettes by year", map[string]any{"year": year, "error": err.Error()})
}
return gazettes, nil
}
func (cs *CachedStore) CreatePatentUrls(ctx context.Context, gazetteID int32, maxAttempts int16, pats []paturlhelper.ParsedPatentURL) error {
return cs.db.CreatePatentUrls(ctx, gazetteID, maxAttempts, pats)
}
func (cs *CachedStore) GetPatentUrlsByGazettePage(ctx context.Context, gazetteID int32, page int, pageSize int) (Page[types.PatentUrl], error) {
return cs.db.GetPatentUrlsByGazettePage(ctx, gazetteID, page, pageSize)
}
func (cs *CachedStore) GetPatentUrlsCount(ctx context.Context, gazetteID int32) (int64, error) {
return cs.db.GetPatentUrlsCount(ctx, gazetteID)
}
func (cs *CachedStore) GetPatentUrlsCompletedCount(ctx context.Context, gazetteID int32) (int64, error) {
return cs.db.GetPatentUrlsCompletedCount(ctx, gazetteID)
}
func (cs *CachedStore) GetPatentUrlsFailedCount(ctx context.Context, gazetteID int32) (int64, error) {
return cs.db.GetPatentUrlsFailedCount(ctx, gazetteID)
}
func (cs *CachedStore) GetUser(ctx context.Context, id int32) (model.User, error) {
return cs.db.GetUser(ctx, id)
}
func (cs *CachedStore) GetUserByEmail(ctx context.Context, email string) (model.User, error) {
return cs.db.GetUserByEmail(ctx, email)
}
func (cs *CachedStore) CreateWorkspace(ctx context.Context, userID int32, name string) (model.Workspace, error) {
return cs.db.CreateWorkspace(ctx, userID, name)
}
func (cs *CachedStore) GetWorkspace(ctx context.Context, id int32) (model.Workspace, error) {
return cs.db.GetWorkspace(ctx, id)
}
func (cs *CachedStore) GetWorkspacesByUser(ctx context.Context, userID int32) ([]model.Workspace, error) {
return cs.db.GetWorkspacesByUser(ctx, userID)
}
func (cs *CachedStore) CountWorkspacesByUser(ctx context.Context, userID int32) (int, error) {
return cs.db.CountWorkspacesByUser(ctx, userID)
}
func (cs *CachedStore) RenameWorkspace(ctx context.Context, id int32, name string) (model.Workspace, error) {
return cs.db.RenameWorkspace(ctx, id, name)
}
func (cs *CachedStore) DeleteWorkspace(ctx context.Context, id int32) error {
return cs.db.DeleteWorkspace(ctx, id)
}
func (cs *CachedStore) AddWorkspaceNode(ctx context.Context, p model.CreateNodeParams) (model.WorkspaceGraph, error) {
return cs.db.AddWorkspaceNode(ctx, p)
}
func (cs *CachedStore) CopyWorkspaceNode(ctx context.Context, targetNodeID int64, targetWsID int32) (model.WorkspaceGraph, error) {
return cs.db.CopyWorkspaceNode(ctx, targetNodeID, targetWsID)
}
func (cs *CachedStore) AddPatentNodeTx(ctx context.Context, p AddPatentNodeParams) (model.WorkspaceGraph, error) {
return cs.db.AddPatentNodeTx(ctx, p)
}
func (cs *CachedStore) AddApplicantNodeTx(ctx context.Context, p AddApplicantNodeParams) (model.WorkspaceGraph, error) {
return cs.db.AddApplicantNodeTx(ctx, p)
}
func (cs *CachedStore) AddAgentNodeTx(ctx context.Context, p AddAgentNodeParams) (model.WorkspaceGraph, error) {
return cs.db.AddAgentNodeTx(ctx, p)
}
func (cs *CachedStore) RefreshNodeMetadata(ctx context.Context, nodeID int64) (model.WorkspaceNode, error) {
return cs.db.RefreshNodeMetadata(ctx, nodeID)
}
func (cs *CachedStore) GetWorkspaceNodesByWorkspace(ctx context.Context, workspaceID int32) ([]model.WorkspaceNode, error) {
return cs.db.GetWorkspaceNodesByWorkspace(ctx, workspaceID)
}
func (cs *CachedStore) GetWorkspaceNodeByEntity(ctx context.Context, workspaceID int32, nodeType model.NodeType, entityID int64) (model.WorkspaceNode, error) {
return cs.db.GetWorkspaceNodeByEntity(ctx, workspaceID, nodeType, entityID)
}
func (cs *CachedStore) UpdateWorkspaceNodeBounds(ctx context.Context, p model.UpdateNodeBoundsParams) error {
return cs.db.UpdateWorkspaceNodeBounds(ctx, p)
}
func (cs *CachedStore) UpdateWorkspaceNodeMetadata(ctx context.Context, nodeID int64, metadata []byte) error {
return cs.db.UpdateWorkspaceNodeMetadata(ctx, nodeID, metadata)
}
func (cs *CachedStore) DeleteWorkspaceNode(ctx context.Context, nodeID int64) error {
return cs.db.DeleteWorkspaceNode(ctx, nodeID)
}
func (cs *CachedStore) MoveWorkspaceNode(ctx context.Context, nodeID int64, targetWorkspaceID int32) error {
return cs.db.MoveWorkspaceNode(ctx, nodeID, targetWorkspaceID)
}
func (cs *CachedStore) AddWorkspaceEdge(ctx context.Context, workspaceID int32, sourceID, targetID int64, edgeType model.EdgeType) (model.WorkspaceEdge, error) {
return cs.db.AddWorkspaceEdge(ctx, workspaceID, sourceID, targetID, edgeType)
}
func (cs *CachedStore) GetWorkspaceEdgesByWorkspace(ctx context.Context, workspaceID int32) ([]model.WorkspaceEdge, error) {
return cs.db.GetWorkspaceEdgesByWorkspace(ctx, workspaceID)
}
func (cs *CachedStore) DeleteWorkspaceEdge(ctx context.Context, edgeID int64) error {
return cs.db.DeleteWorkspaceEdge(ctx, edgeID)
}
func (cs *CachedStore) GetWorkspaceGraph(ctx context.Context, workspaceID int32) (model.WorkspaceGraph, error) {
return cs.db.GetWorkspaceGraph(ctx, workspaceID)
}
func (cs *CachedStore) BulkDeleteNodes(ctx context.Context, workspaceID int32, nodeIDs []int64) error {
return cs.db.BulkDeleteNodes(ctx, workspaceID, nodeIDs)
}
func (cs *CachedStore) BulkMoveNodes(ctx context.Context, workspaceID int32, nodeIDs []int64, targetWorkspaceID int32) error {
return cs.db.BulkMoveNodes(ctx, workspaceID, nodeIDs, targetWorkspaceID)
}
func (cs *CachedStore) BulkCopyNodes(ctx context.Context, workspaceID int32, nodeIDs []int64, targetWorkspaceID int32) (model.WorkspaceGraph, error) {
return cs.db.BulkCopyNodes(ctx, workspaceID, nodeIDs, targetWorkspaceID)
}
func (cs *CachedStore) UpdatePatentDeadlineDate(ctx context.Context, id int64, date time.Time) error {
if err := cs.cache.Del(prefixPatent.key(id)).Err(); err != nil {
cs.logger.Warning(ctx, "failed to invalidate patent cache", map[string]any{"id": id, "error": err.Error()})
}
if err := cs.cache.Del(prefixPatentFull.key(id)).Err(); err != nil {
cs.logger.Warning(ctx, "failed to invalidate patent with relations cache", map[string]any{"id": id, "error": err.Error()})
}
return cs.db.UpdatePatentDeadlineDate(ctx, id, date)
}
func (cs *CachedStore) UpdateAgentEmail(ctx context.Context, id int64, email string) error {
if err := cs.cache.Del(prefixAgent.key(id)).Err(); err != nil {
cs.logger.Warning(ctx, "failed to invalidate agent cache", map[string]any{"id": id, "error": err.Error()})
}
return cs.db.UpdateAgentEmail(ctx, id, email)
}
func (cs *CachedStore) BulkUpdateNodePositions(ctx context.Context, workspaceID int32, updates []model.UpdateNodeBoundsParams) error {
return cs.db.BulkUpdateNodePositions(ctx, workspaceID, updates)
}
package store
import (
"context"
"strconv"
"strings"
"time"
"codeberg.org/at375/patent-flow/gen/sqlcgen"
"codeberg.org/at375/patent-flow/internal/model"
"codeberg.org/at375/patent-flow/util/mapper"
)
func (s *PFStore) GetGazette(ctx context.Context, id int32) (model.Gazette, error) {
gazette, err := s.roqueries.GetGazette(ctx, id)
if err != nil {
return model.Gazette{}, s.error(ctx, err, "failed to get gazette", map[string]any{
"gazette_id": id,
})
}
return mapper.GazetteFromDB(gazette), nil
}
func (s *PFStore) GetGazettesByYear(ctx context.Context, year string) ([]model.Gazette, error) {
gazettes, err := s.roqueries.GetGazettesByYear(ctx, year)
if err != nil {
return nil, s.error(ctx, err, "failed to get gazettes by year", map[string]any{
"year": year,
})
}
return mapper.GazettesFromDB(gazettes), nil
}
func (s *PFStore) CreateOrGetGazette(ctx context.Context, year string, name string, date string, count string) (model.Gazette, error) {
parsedDate, err := time.Parse("02.01.2006", date)
if err != nil {
return model.Gazette{}, s.error(ctx, err, "failed to parse gazette date", map[string]any{
"date": date,
})
}
countInt, err := strconv.Atoi(strings.ReplaceAll(count, ",", ""))
if err != nil {
return model.Gazette{}, s.error(ctx, err, "failed to parse gazette count", map[string]any{
"count": count,
})
}
gazette, err := s.rwqueries.CreateOrGetGazette(ctx, sqlcgen.CreateOrGetGazetteParams{
Year: year,
Name: name,
Date: parsedDate,
Count: int32(countInt),
})
if err != nil {
return model.Gazette{}, s.error(ctx, err, "failed to create or get gazette", map[string]any{
"year": year,
"name": name,
"date": date,
})
}
return mapper.GazetteFromDB(gazette), nil
}
package store
import (
"fmt"
"net/url"
"strconv"
"strings"
)
const (
SortAsc = "ASC"
SortDesc = "DESC"
DefaultPageSize = 20
MaxPageSize = 100
MaxSearchLength = 200
MaxSortFields = 3
)
type SortField struct {
Column string
Dir string
}
type ListParams struct {
Page int
PageSize int
Search string
Filters map[string]string
Sort []SortField
}
func ParseListParams(q url.Values, allowedSorts map[string]string, allowedFilters []string) (ListParams, error) {
page, _ := strconv.Atoi(q.Get("page"))
if page < 1 {
page = 1
}
pageSize, _ := strconv.Atoi(q.Get("page_size"))
switch {
case pageSize < 1:
pageSize = DefaultPageSize
case pageSize > MaxPageSize:
pageSize = MaxPageSize
}
sortsLen := len(q["sort"])
if sortsLen > MaxSortFields {
return ListParams{}, fmt.Errorf("too many sort fields, maximum is %d", MaxSortFields)
}
sorts := make([]SortField, 0, sortsLen)
for _, raw := range q["sort"] {
parts := strings.SplitN(strings.TrimSpace(raw), ":", 2)
key := parts[0]
col, ok := allowedSorts[key]
if !ok {
return ListParams{}, fmt.Errorf("sort field %q is not supported", key)
}
dir := SortAsc
if len(parts) == 2 && strings.EqualFold(parts[1], "desc") {
dir = SortDesc
}
sorts = append(sorts, SortField{Column: col, Dir: dir})
}
filters := make(map[string]string, len(allowedFilters))
for _, key := range allowedFilters {
if v := strings.TrimSpace(q.Get(key)); v != "" {
filters[key] = v
}
}
search := strings.TrimSpace(q.Get("search"))
if len(search) > MaxSearchLength {
return ListParams{}, fmt.Errorf("search term exceeds maximum length of %d", MaxSearchLength)
}
return ListParams{
Page: page,
PageSize: pageSize,
Search: search,
Filters: filters,
Sort: sorts,
}, nil
}
package store
import (
"context"
"errors"
"strings"
"time"
"codeberg.org/at375/patent-flow/gen/sqlcgen"
"codeberg.org/at375/patent-flow/internal/model"
"codeberg.org/at375/patent-flow/util/mapper"
sq "github.com/Masterminds/squirrel"
)
func (s *PFStore) GetPatent(ctx context.Context, id int64) (model.Patent, error) {
patent, err := s.roqueries.GetPatent(ctx, id)
if err != nil {
return model.Patent{}, s.error(ctx, err, "GetPatent", map[string]any{
"patent_id": id,
})
}
// cant fail, only fails on wrong type
p, err := mapper.PatentFromDB(patent)
if err != nil {
return model.Patent{}, s.error(ctx, err, "GetPatent", map[string]any{
"patent_id": id,
})
}
return p, nil
}
func (s *PFStore) GetPatentsByGazette(ctx context.Context, gazetteID int32) ([]model.Patent, error) {
rows, err := s.roqueries.GetPatentsByGazette(ctx, gazetteID)
if err != nil {
return nil, s.error(ctx, err, "GetPatentsByGazette", nil)
}
out := make([]model.Patent, len(rows))
for i, r := range rows {
out[i] = model.Patent{
ID: r.ID,
Name: r.Name,
Number: r.Number,
GazetteID: r.GazetteID,
}
}
return out, nil
}
func (s *PFStore) GetPatentWithRelations(ctx context.Context, id int64) (model.PatentWithRelations, error) {
p, err := s.roqueries.GetPatent(ctx, id)
if err != nil {
return model.PatentWithRelations{}, s.error(ctx, err, "GetPatentWithRelations", map[string]any{
"patent_id": id,
})
}
// cant fail, only fails on wrong type
patent, err := mapper.PatentFromDB(p)
if err != nil {
return model.PatentWithRelations{}, s.error(ctx, err, "GetPatentWithRelations", map[string]any{
"patent_id": id,
})
}
ap, err := s.roqueries.GetApplicant(ctx, patent.ApplicantID)
if err != nil {
return model.PatentWithRelations{}, s.error(ctx, err, "GetPatentWithRelations: applicant missing", nil)
}
applicant := mapper.ApplicantFromDB(ap)
ag, err := s.roqueries.GetAgentsByPatent(ctx, id)
if err != nil {
return model.PatentWithRelations{}, s.error(ctx, err, "GetPatentWithRelations: agents missing", nil)
}
agents := mapper.AgentsFromDB(ag)
return model.PatentWithRelations{
Patent: patent,
Applicant: applicant,
Agents: agents,
}, nil
}
var AllowedPatentSorts = map[string]string{
"id": "patents.id",
"name": "patents.name",
"number": "patents.number",
"pub_date": "patents.pub_date",
"priority_date": "patents.priority_date",
"deadline_date": "patents.deadline_date",
"created_at": "patents.created_at",
"updated_at": "patents.updated_at",
"applicant_id": "patents.applicant_id",
"applicant_name": "applicants.name",
"agent_name": `(
SELECT MIN(a.name)
FROM patent_agents pa
JOIN agents a ON a.id = pa.agent_id
WHERE pa.patent_id = patents.id
)`,
"country": `(
SELECT MIN(pc.country)
FROM patent_countries pc
WHERE pc.patent_id = patents.id
)`,
}
var AllowedPatentFilters = []string{
"applicant_id",
"gazette_id",
"agent_id",
"countries",
"expired",
}
type PatentListItem struct {
model.Patent
ApplicantName string `json:"applicant_name"`
AgentNames []string `json:"agent_names"`
AgentIDs []int64 `json:"agent_ids"`
}
func (s *PFStore) UpdatePatentDeadlineDate(ctx context.Context, id int64, date time.Time) error {
err := s.rwqueries.UpdatePatentDeadlineDate(ctx, sqlcgen.UpdatePatentDeadlineDateParams{
ID: id,
DeadlineDate: date,
})
if err != nil {
return s.error(ctx, err, "UpdatePatentDeadlineDate", map[string]any{
"patent_id": id,
})
}
return nil
}
func (s *PFStore) ListPatents(ctx context.Context, p ListParams) (Page[PatentListItem], error) {
q := psql.
Select(
"patents.id",
"patents.name",
"patents.number",
"patents.applicant_id",
"patents.gazette_id",
"patents.pub_date",
"patents.priority_date",
"patents.deadline_date",
"patents.created_at",
"patents.updated_at",
"applicants.name AS applicant_name",
"COALESCE(agg_agents.agent_ids, '{}') AS agent_ids",
"COALESCE(agg_agents.agent_names, '{}') AS agent_names",
"COALESCE(agg_countries.countries, '{}') AS countries",
"COUNT(*) OVER() AS total",
).
From("patents").
Join("applicants ON applicants.id = patents.applicant_id").
JoinClause(`
LEFT JOIN LATERAL (
SELECT
ARRAY_AGG(a.id ORDER BY a.name) AS agent_ids,
ARRAY_AGG(a.name ORDER BY a.name) AS agent_names
FROM patent_agents pa
JOIN agents a ON a.id = pa.agent_id
WHERE pa.patent_id = patents.id
) agg_agents ON true`).
JoinClause(`
LEFT JOIN LATERAL (
SELECT ARRAY_AGG(pc.country ORDER BY pc.country) AS countries
FROM patent_countries pc
WHERE pc.patent_id = patents.id
) agg_countries ON true`)
if v, ok := p.Filters["applicant_id"]; ok {
q = q.Where(sq.Eq{"patents.applicant_id": v})
}
if v, ok := p.Filters["gazette_id"]; ok {
q = q.Where(sq.Eq{"patents.gazette_id": v})
}
if v, ok := p.Filters["expired"]; !ok || v != "true" {
q = q.Where(sq.Expr("patents.deadline_date >= CURRENT_DATE"))
}
if v, ok := p.Filters["agent_id"]; ok {
q = q.Where(
sq.Expr(
"EXISTS (SELECT 1 FROM patent_agents pa WHERE pa.patent_id = patents.id AND pa.agent_id = ?)",
v,
),
)
}
if v, ok := p.Filters["countries"]; ok && v != "" {
countries := strings.Split(v, ",")
if len(countries) > 50 {
return Page[PatentListItem]{}, s.error(ctx, errors.New("too many countries"), "ListPatents: build sql", nil)
}
countriesAny := make([]interface{}, len(countries))
for i, s := range countries {
countriesAny[i] = strings.TrimSpace(s)
}
q = q.Where(
sq.Expr(
"EXISTS (SELECT 1 FROM patent_countries pc WHERE pc.patent_id = patents.id AND pc.country IN ("+
sq.Placeholders(len(countries))+"))",
countriesAny...,
),
)
}
if p.Search != "" {
pat := searchPattern(p.Search)
q = q.Where(sq.Or{
ilike("patents.name", pat),
ilike("patents.number", pat),
ilike("applicants.name", pat),
sq.Expr(
`EXISTS (
SELECT 1
FROM patent_agents pa
JOIN agents a ON a.id = pa.agent_id
WHERE pa.patent_id = patents.id
AND a.name ILIKE ?
)`,
pat,
),
sq.Expr(
`EXISTS (
SELECT 1
FROM patent_countries pc
WHERE pc.patent_id = patents.id
AND pc.country ILIKE ?
)`,
pat,
),
})
}
q = applyListParams(q, p)
query, args, err := q.ToSql()
if err != nil {
return Page[PatentListItem]{}, s.error(ctx, err, "ListPatents: build sql", nil)
}
rows, err := s.ropool.Query(ctx, query, args...)
if err != nil {
return Page[PatentListItem]{}, s.error(ctx, err, "ListPatents: query", nil)
}
defer rows.Close()
var (
items []PatentListItem
total int
)
for rows.Next() {
var r PatentListItem
if err := rows.Scan(
&r.ID,
&r.Name,
&r.Number,
&r.ApplicantID,
&r.GazetteID,
&r.PubDate,
&r.PriorityDate,
&r.DeadlineDate,
&r.CreatedAt,
&r.UpdatedAt,
&r.ApplicantName,
&r.AgentIDs,
&r.AgentNames,
&r.Countries,
&total,
); err != nil {
return Page[PatentListItem]{}, s.error(ctx, err, "ListPatents: scan", nil)
}
items = append(items, r)
}
if err := rows.Err(); err != nil {
return Page[PatentListItem]{}, s.error(ctx, err, "ListPatents: iterate", nil)
}
return newPage(items, total, p.Page, p.PageSize), nil
}
package store
import (
"context"
"fmt"
"codeberg.org/at375/patent-flow/common/types"
"codeberg.org/at375/patent-flow/gen/sqlcgen"
"codeberg.org/at375/patent-flow/internal/util/paturlhelper"
"codeberg.org/at375/patent-flow/util/mapper"
)
func (s *PFStore) GetPatentUrlsByGazettePage(ctx context.Context, gazetteID int32, page int, pageSize int) (Page[types.PatentUrl], error) {
var res Page[types.PatentUrl]
offset := (page - 1) * pageSize
patentUrls, err := s.roqueries.GetPatentUrlsByGazettePaginated(
ctx,
sqlcgen.GetPatentUrlsByGazettePaginatedParams{
GazetteID: gazetteID,
Limit: int32(pageSize),
Offset: int32(offset),
},
)
if err != nil {
return Page[types.PatentUrl]{}, s.error(ctx, err, "failed to get patent urls by gazette page", map[string]any{
"gazette_id": gazetteID,
"page": page,
"page_size": pageSize,
})
}
total, err := s.GetPatentUrlsCount(ctx, gazetteID)
if err != nil {
return Page[types.PatentUrl]{}, s.error(ctx, err, "failed to get patent urls count for gazette", map[string]any{
"gazette_id": gazetteID,
})
}
res.Items = mapper.PatentUrlsFromDB(patentUrls)
res.Total = int(total)
res.Page = page
res.PageSize = pageSize
res.TotalPages = (res.Total + pageSize - 1) / pageSize
return res, nil
}
// TODO: refactor all count getter to GetPatentUrlStats that return all 3
func (s *PFStore) GetPatentUrlsCount(ctx context.Context, gazetteID int32) (int64, error) {
count, err := s.roqueries.GetPatentUrlsCount(ctx, gazetteID)
if err != nil {
return 0, s.error(ctx, err, "failed to get patent urls count", map[string]any{
"gazette_id": gazetteID,
})
}
return count, nil
}
func (s *PFStore) GetPatentUrlsCompletedCount(ctx context.Context, gazetteID int32) (int64, error) {
count, err := s.roqueries.GetPatentUrlsCompletedCount(ctx, gazetteID)
if err != nil {
return 0, s.error(ctx, err, "failed to get patent urls completed count", map[string]any{
"gazette_id": gazetteID,
})
}
return count, nil
}
func (s *PFStore) GetPatentUrlsFailedCount(ctx context.Context, gazetteID int32) (int64, error) {
count, err := s.roqueries.GetPatentUrlsFailedCount(ctx, gazetteID)
if err != nil {
return 0, s.error(ctx, err, "failed to get patent urls failed count", map[string]any{
"gazette_id": gazetteID,
})
}
return count, nil
}
func (s *PFStore) CreatePatentUrls(ctx context.Context, gazetteID int32, maxAttempts int16, pats []paturlhelper.ParsedPatentURL) error {
patsCnt := len(pats)
params := make([]sqlcgen.CreatePatentUrlsParams, patsCnt)
for i, p := range pats {
params[i] = sqlcgen.CreatePatentUrlsParams{
GazetteID: gazetteID,
Url: p.URL,
Name: p.Name,
MaxAttempts: maxAttempts,
}
}
i, err := s.rwqueries.CreatePatentUrls(ctx, params)
if err != nil {
return s.error(ctx, err, "failed to bulk create patent urls", map[string]any{
"gazette_id": gazetteID,
"count": patsCnt,
})
}
if i != int64(patsCnt) {
s.logger.Warning(
ctx,
fmt.Sprintf("during PatentUrls insert in store.CreatePatentUrls() not all patents were inserted; expected: %v, inserted: %v", patsCnt, i),
nil,
)
}
return nil
}
package store
import (
"fmt"
sq "github.com/Masterminds/squirrel"
)
var psql = sq.StatementBuilder.PlaceholderFormat(sq.Dollar)
type Page[T any] struct {
Items []T `json:"items"`
Total int `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
TotalPages int `json:"total_pages"`
}
func newPage[T any](items []T, total, page, pageSize int) Page[T] {
if items == nil {
items = []T{}
}
totalPages := 0
if pageSize > 0 {
totalPages = total / pageSize
if total%pageSize > 0 {
totalPages++
}
}
return Page[T]{
Items: items,
Total: total,
Page: page,
PageSize: pageSize,
TotalPages: totalPages,
}
}
func applyListParams(q sq.SelectBuilder, p ListParams) sq.SelectBuilder {
for _, s := range p.Sort {
q = q.OrderBy(fmt.Sprintf("%s %s NULLS LAST", s.Column, s.Dir))
}
offset := uint64((p.Page - 1) * p.PageSize)
return q.Limit(uint64(p.PageSize)).Offset(offset)
}
func ilike(col, pattern string) sq.Sqlizer {
return sq.Expr(col+" ILIKE ?", pattern)
}
func searchPattern(term string) string {
return "%" + term + "%"
}
package store
import (
"context"
"fmt"
"time"
"codeberg.org/at375/patent-flow/common/types"
"codeberg.org/at375/patent-flow/gen/sqlcgen"
"codeberg.org/at375/patent-flow/internal/model"
"codeberg.org/at375/patent-flow/internal/util/paturlhelper"
"codeberg.org/at375/patent-flow/logger"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
type Queries interface {
GetAgent(ctx context.Context, id int64) (sqlcgen.Agent, error)
GetAgentsByPatent(ctx context.Context, patentID int64) ([]sqlcgen.Agent, error)
UpdateAgentEmail(ctx context.Context, arg sqlcgen.UpdateAgentEmailParams) error
GetApplicant(ctx context.Context, id int64) (sqlcgen.Applicant, error)
CreateOrGetGazette(ctx context.Context, arg sqlcgen.CreateOrGetGazetteParams) (sqlcgen.Gazette, error)
GetGazette(ctx context.Context, id int32) (sqlcgen.Gazette, error)
GetGazettesByYear(ctx context.Context, year string) ([]sqlcgen.Gazette, error)
GetPatent(ctx context.Context, id int64) (sqlcgen.GetPatentRow, error)
GetPatentsByGazette(ctx context.Context, gazetteID int32) ([]sqlcgen.GetPatentsByGazetteRow, error)
GetPatentsByApplicant(ctx context.Context, applicantID int64) ([]sqlcgen.GetPatentsByApplicantRow, error)
GetPatentsByAgent(ctx context.Context, agentID int64) ([]sqlcgen.GetPatentsByAgentRow, error)
UpdatePatentDeadlineDate(ctx context.Context, arg sqlcgen.UpdatePatentDeadlineDateParams) error
CreatePatentUrls(ctx context.Context, arg []sqlcgen.CreatePatentUrlsParams) (int64, error)
GetPatentUrl(ctx context.Context, id int64) (sqlcgen.PatentUrl, error)
GetPatentUrlsByGazette(ctx context.Context, gazetteID int32) ([]sqlcgen.PatentUrl, error)
GetPatentUrlsByGazettePaginated(ctx context.Context, arg sqlcgen.GetPatentUrlsByGazettePaginatedParams) ([]sqlcgen.PatentUrl, error)
GetPatentUrlsCount(ctx context.Context, gazetteID int32) (int64, error)
GetPatentUrlsCompletedCount(ctx context.Context, gazetteID int32) (int64, error)
GetPatentUrlsFailedCount(ctx context.Context, gazetteID int32) (int64, error)
RequeueStuckPatentUrls(ctx context.Context) error
CreateUser(ctx context.Context, arg sqlcgen.CreateUserParams) (sqlcgen.User, error)
GetUser(ctx context.Context, id int32) (sqlcgen.User, error)
GetUserByEmail(ctx context.Context, email string) (sqlcgen.User, error)
CountWorkspacesByUser(ctx context.Context, userID int32) (int64, error)
CreateWorkspace(ctx context.Context, arg sqlcgen.CreateWorkspaceParams) (sqlcgen.Workspace, error)
GetWorkspaceEdge(ctx context.Context, arg sqlcgen.GetWorkspaceEdgeParams) (sqlcgen.WorkspaceEdge, error)
DeleteWorkspaceEdgesByNode(ctx context.Context, nodeID int64) error
CreateOrGetWorkspaceEdge(ctx context.Context, arg sqlcgen.CreateOrGetWorkspaceEdgeParams) (sqlcgen.WorkspaceEdge, error)
CreateOrGetWorkspaceNode(ctx context.Context, arg sqlcgen.CreateOrGetWorkspaceNodeParams) (sqlcgen.WorkspaceNode, error)
DeleteWorkspace(ctx context.Context, id int32) error
DeleteWorkspaceEdge(ctx context.Context, id int64) error
DeleteWorkspaceNode(ctx context.Context, id int64) error
GetWorkspace(ctx context.Context, id int32) (sqlcgen.Workspace, error)
GetWorkspaceEdgesByWorkspace(ctx context.Context, workspaceID int32) ([]sqlcgen.WorkspaceEdge, error)
GetWorkspaceNode(ctx context.Context, id int64) (sqlcgen.WorkspaceNode, error)
GetWorkspaceNodeByEntity(ctx context.Context, arg sqlcgen.GetWorkspaceNodeByEntityParams) (sqlcgen.WorkspaceNode, error)
GetWorkspaceNodesByWorkspace(ctx context.Context, workspaceID int32) ([]sqlcgen.WorkspaceNode, error)
GetWorkspacesByUser(ctx context.Context, userID int32) ([]sqlcgen.Workspace, error)
MoveWorkspaceNode(ctx context.Context, arg sqlcgen.MoveWorkspaceNodeParams) (sqlcgen.WorkspaceNode, error)
RenameWorkspace(ctx context.Context, arg sqlcgen.RenameWorkspaceParams) (sqlcgen.Workspace, error)
UpdateWorkspaceNodePosition(ctx context.Context, arg sqlcgen.UpdateWorkspaceNodePositionParams) error
UpdateWorkspaceNodeMetadata(ctx context.Context, arg sqlcgen.UpdateWorkspaceNodeMetadataParams) error
WithTx(tx pgx.Tx) *sqlcgen.Queries
}
type Store interface {
GetPatent(ctx context.Context, id int64) (model.Patent, error)
GetPatentWithRelations(ctx context.Context, patentID int64) (model.PatentWithRelations, error)
GetPatentsByGazette(ctx context.Context, gazetteID int32) ([]model.Patent, error)
ListPatents(ctx context.Context, p ListParams) (Page[PatentListItem], error)
UpdatePatentDeadlineDate(ctx context.Context, id int64, date time.Time) error
GetAgent(ctx context.Context, id int64) (model.Agent, error)
ListAgents(ctx context.Context, p ListParams) (Page[model.Agent], error)
UpdateAgentEmail(ctx context.Context, id int64, email string) error
GetApplicant(ctx context.Context, id int64) (model.Applicant, error)
ListApplicants(ctx context.Context, p ListParams) (Page[model.Applicant], error)
CreateOrGetGazette(ctx context.Context, year string, name string, date string, count string) (model.Gazette, error)
CreatePatentUrls(ctx context.Context, gazetteID int32, maxAttempts int16, pats []paturlhelper.ParsedPatentURL) error
GetPatentUrlsByGazettePage(ctx context.Context, gazetteID int32, page int, pageSize int) (Page[types.PatentUrl], error)
GetPatentUrlsCount(ctx context.Context, gazetteID int32) (int64, error)
GetPatentUrlsCompletedCount(ctx context.Context, gazetteID int32) (int64, error)
GetPatentUrlsFailedCount(ctx context.Context, gazetteID int32) (int64, error)
GetGazette(ctx context.Context, id int32) (model.Gazette, error)
GetGazettesByYear(ctx context.Context, year string) ([]model.Gazette, error)
GetUser(ctx context.Context, id int32) (model.User, error)
GetUserByEmail(ctx context.Context, email string) (model.User, error)
CreateWorkspace(ctx context.Context, userID int32, name string) (model.Workspace, error)
GetWorkspace(ctx context.Context, id int32) (model.Workspace, error)
GetWorkspacesByUser(ctx context.Context, userID int32) ([]model.Workspace, error)
CountWorkspacesByUser(ctx context.Context, userID int32) (int, error)
RenameWorkspace(ctx context.Context, id int32, name string) (model.Workspace, error)
DeleteWorkspace(ctx context.Context, id int32) error
AddWorkspaceNode(ctx context.Context, p model.CreateNodeParams) (model.WorkspaceGraph, error)
CopyWorkspaceNode(ctx context.Context, targetNodeID int64, targetWsID int32) (model.WorkspaceGraph, error)
AddPatentNodeTx(ctx context.Context, p AddPatentNodeParams) (model.WorkspaceGraph, error)
AddApplicantNodeTx(ctx context.Context, p AddApplicantNodeParams) (model.WorkspaceGraph, error)
AddAgentNodeTx(ctx context.Context, p AddAgentNodeParams) (model.WorkspaceGraph, error)
RefreshNodeMetadata(ctx context.Context, nodeID int64) (model.WorkspaceNode, error)
GetWorkspaceNodesByWorkspace(ctx context.Context, workspaceID int32) ([]model.WorkspaceNode, error)
GetWorkspaceNodeByEntity(ctx context.Context, workspaceID int32, nodeType model.NodeType, entityID int64) (model.WorkspaceNode, error)
UpdateWorkspaceNodeBounds(ctx context.Context, p model.UpdateNodeBoundsParams) error
UpdateWorkspaceNodeMetadata(ctx context.Context, nodeID int64, metadata []byte) error
DeleteWorkspaceNode(ctx context.Context, nodeID int64) error
MoveWorkspaceNode(ctx context.Context, nodeID int64, targetWorkspaceID int32) error
AddWorkspaceEdge(ctx context.Context, workspaceID int32, sourceID, targetID int64, edgeType model.EdgeType) (model.WorkspaceEdge, error)
GetWorkspaceEdgesByWorkspace(ctx context.Context, workspaceID int32) ([]model.WorkspaceEdge, error)
DeleteWorkspaceEdge(ctx context.Context, edgeID int64) error
GetWorkspaceGraph(ctx context.Context, workspaceID int32) (model.WorkspaceGraph, error)
BulkDeleteNodes(ctx context.Context, workspaceID int32, nodeIDs []int64) error
BulkMoveNodes(ctx context.Context, workspaceID int32, nodeIDs []int64, targetWorkspaceID int32) error
BulkCopyNodes(ctx context.Context, workspaceID int32, nodeIDs []int64, targetWorkspaceID int32) (model.WorkspaceGraph, error)
BulkUpdateNodePositions(ctx context.Context, workspaceID int32, updates []model.UpdateNodeBoundsParams) error
}
type PFStore struct {
ropool *pgxpool.Pool
rwpool *pgxpool.Pool
logger logger.Logger
roqueries Queries
rwqueries Queries
}
func NewStore(ropool *pgxpool.Pool, rwpool *pgxpool.Pool, logger logger.Logger) *PFStore {
return &PFStore{
ropool: ropool,
rwpool: rwpool,
roqueries: sqlcgen.New(ropool),
rwqueries: sqlcgen.New(rwpool),
logger: logger,
}
}
func (s *PFStore) error(ctx context.Context, err error, msg string, fields map[string]any) error {
if fields == nil {
fields = map[string]any{}
}
fields["error"] = err.Error()
s.logger.Error(ctx, msg, fields)
return fmt.Errorf("%s: %w", msg, err)
}
func (s *PFStore) execTx(ctx context.Context, fn func(Queries) error) error {
tx, err := s.rwpool.Begin(ctx)
if err != nil {
return err
}
q := s.rwqueries.WithTx(tx)
err = fn(q)
if err != nil {
if rbErr := tx.Rollback(ctx); rbErr != nil {
return fmt.Errorf("tx err: %v, rb err: %v", err, rbErr)
}
return err
}
return tx.Commit(ctx)
}
package store
import (
"context"
"codeberg.org/at375/patent-flow/internal/model"
"codeberg.org/at375/patent-flow/util/mapper"
)
func (s *PFStore) GetUser(ctx context.Context, id int32) (model.User, error) {
user, err := s.roqueries.GetUser(ctx, id)
if err != nil {
return model.User{}, s.error(ctx, err, "failed to get user", map[string]any{
"user_id": id,
})
}
return mapper.UserFromDB(user), nil
}
func (s *PFStore) GetUserByEmail(ctx context.Context, email string) (model.User, error) {
user, err := s.roqueries.GetUserByEmail(ctx, email)
if err != nil {
return model.User{}, s.error(ctx, err, "failed to get user by email", map[string]any{
"email": email,
})
}
return mapper.UserFromDB(user), nil
}
package store
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"codeberg.org/at375/patent-flow/gen/sqlcgen"
"codeberg.org/at375/patent-flow/internal/model"
"codeberg.org/at375/patent-flow/util/mapper"
"github.com/jackc/pgx/v5"
)
const (
defaultNodeWidth = 6
defaultNodeHeight = 3
)
type patentNodeMeta struct {
Name string `json:"name"`
PubDate time.Time `json:"pub_date"`
PriorityDate time.Time `json:"priority_date"`
DeadlineDate time.Time `json:"deadline_date"`
}
type applicantNodeMeta struct {
Name string `json:"name"`
Address string `json:"address"`
Postcode string `json:"postcode"`
Country string `json:"country"`
}
type agentNodeMeta struct {
Name string `json:"name"`
Email string `json:"email"`
Address string `json:"address"`
Postcode string `json:"postcode"`
Country string `json:"country"`
}
func marshalNodeMeta(v any) json.RawMessage {
b, err := json.Marshal(v)
if err != nil {
return json.RawMessage(`{}`)
}
return b
}
func (s *PFStore) CreateWorkspace(ctx context.Context, userID int32, name string) (model.Workspace, error) {
row, err := s.rwqueries.CreateWorkspace(ctx, sqlcgen.CreateWorkspaceParams{
UserID: userID,
Name: name,
})
if err != nil {
return model.Workspace{}, s.error(ctx, err, "CreateWorkspace", nil)
}
return mapper.WorkspaceFromRow(row), nil
}
func (s *PFStore) GetWorkspace(ctx context.Context, id int32) (model.Workspace, error) {
row, err := s.roqueries.GetWorkspace(ctx, id)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return model.Workspace{}, fmt.Errorf("workspace %d not found", id)
}
return model.Workspace{}, s.error(ctx, err, "GetWorkspace", nil)
}
return mapper.WorkspaceFromRow(row), nil
}
func (s *PFStore) GetWorkspacesByUser(ctx context.Context, userID int32) ([]model.Workspace, error) {
rows, err := s.roqueries.GetWorkspacesByUser(ctx, userID)
if err != nil {
return nil, s.error(ctx, err, "GetWorkspacesByUser", nil)
}
out := make([]model.Workspace, len(rows))
for i, r := range rows {
out[i] = mapper.WorkspaceFromRow(r)
}
return out, nil
}
func (s *PFStore) CountWorkspacesByUser(ctx context.Context, userID int32) (int, error) {
count, err := s.roqueries.CountWorkspacesByUser(ctx, userID)
if err != nil {
return 0, s.error(ctx, err, "CountWorkspacesByUser", nil)
}
return int(count), nil
}
func (s *PFStore) RenameWorkspace(ctx context.Context, id int32, name string) (model.Workspace, error) {
row, err := s.rwqueries.RenameWorkspace(ctx, sqlcgen.RenameWorkspaceParams{
ID: id,
Name: name,
})
if err != nil {
return model.Workspace{}, s.error(ctx, err, "RenameWorkspace", nil)
}
return mapper.WorkspaceFromRow(row), nil
}
func (s *PFStore) DeleteWorkspace(ctx context.Context, id int32) error {
if err := s.rwqueries.DeleteWorkspace(ctx, id); err != nil {
return s.error(ctx, err, "DeleteWorkspace", nil)
}
return nil
}
func (s *PFStore) AddWorkspaceNode(ctx context.Context, p model.CreateNodeParams) (model.WorkspaceGraph, error) {
meta := p.Metadata
if meta == nil {
meta = json.RawMessage(`{}`)
}
row, err := s.rwqueries.CreateOrGetWorkspaceNode(ctx, sqlcgen.CreateOrGetWorkspaceNodeParams{
WorkspaceID: p.WorkspaceID,
NodeType: string(p.NodeType),
EntityID: p.EntityID,
PosX: p.PosX,
PosY: p.PosY,
Width: p.Width,
Height: p.Height,
Metadata: meta,
})
if err != nil {
return model.WorkspaceGraph{}, s.error(ctx, err, "AddWorkspaceNode", nil)
}
node := mapper.WorkspaceNodeFromRow(row)
graph := model.WorkspaceGraph{
Nodes: []model.WorkspaceNode{node},
}
return graph, nil
}
func (s *PFStore) copyWorkspaceNode(ctx context.Context, q Queries, targetNodeId int64, targetWsID int32) (model.WorkspaceGraph, error) {
var graph model.WorkspaceGraph
srcNode, err := q.GetWorkspaceNode(ctx, targetNodeId)
if err != nil {
return model.WorkspaceGraph{}, err
}
node, err := q.CreateOrGetWorkspaceNode(ctx, sqlcgen.CreateOrGetWorkspaceNodeParams{
WorkspaceID: targetWsID,
NodeType: srcNode.NodeType,
EntityID: srcNode.EntityID,
PosX: srcNode.PosX,
PosY: srcNode.PosY,
Width: srcNode.Width,
Height: srcNode.Height,
Metadata: srcNode.Metadata,
})
if err != nil {
return model.WorkspaceGraph{}, err
}
copiedNode := mapper.WorkspaceNodeFromRow(node)
graph.Nodes = append(graph.Nodes, copiedNode)
edges, err := connectWithExisting(ctx, q, copiedNode)
if err != nil {
return model.WorkspaceGraph{}, err
}
for _, e := range edges {
graph.Edges = append(graph.Edges, e)
}
return graph, nil
}
func (s *PFStore) CopyWorkspaceNode(ctx context.Context, targetNodeId int64, targetWsID int32) (model.WorkspaceGraph, error) {
var graph model.WorkspaceGraph
err := s.execTx(ctx, func(q Queries) error {
var err error
graph, err = s.copyWorkspaceNode(ctx, q, targetNodeId, targetWsID)
return err
})
if err != nil {
return model.WorkspaceGraph{}, s.error(ctx, err, "CopyWorkspaceNode", nil)
}
return graph, nil
}
func (s *PFStore) GetWorkspaceNodesByWorkspace(ctx context.Context, workspaceID int32) ([]model.WorkspaceNode, error) {
rows, err := s.roqueries.GetWorkspaceNodesByWorkspace(ctx, workspaceID)
if err != nil {
return nil, s.error(ctx, err, "GetWorkspaceNodesByWorkspace", nil)
}
out := make([]model.WorkspaceNode, len(rows))
for i, r := range rows {
out[i] = mapper.WorkspaceNodeFromRow(r)
}
return out, nil
}
func (s *PFStore) GetWorkspaceNodeByEntity(ctx context.Context, workspaceID int32, nodeType model.NodeType, entityID int64) (model.WorkspaceNode, error) {
row, err := s.roqueries.GetWorkspaceNodeByEntity(ctx, sqlcgen.GetWorkspaceNodeByEntityParams{
WorkspaceID: workspaceID,
NodeType: string(nodeType),
EntityID: entityID,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return model.WorkspaceNode{}, fmt.Errorf("node not found in workspace %d", workspaceID)
}
return model.WorkspaceNode{}, s.error(ctx, err, "GetWorkspaceNodeByEntity", nil)
}
return mapper.WorkspaceNodeFromRow(row), nil
}
func (s *PFStore) UpdateWorkspaceNodeBounds(ctx context.Context, p model.UpdateNodeBoundsParams) error {
err := s.rwqueries.UpdateWorkspaceNodePosition(ctx, sqlcgen.UpdateWorkspaceNodePositionParams{
ID: p.NodeID,
PosX: p.PosX,
PosY: p.PosY,
Width: p.Width,
Height: p.Height,
})
if err != nil {
return s.error(ctx, err, "UpdateWorkspaceNodeBounds", nil)
}
return nil
}
func (s *PFStore) UpdateWorkspaceNodeMetadata(ctx context.Context, nodeID int64, metadata []byte) error {
if err := s.rwqueries.UpdateWorkspaceNodeMetadata(ctx, sqlcgen.UpdateWorkspaceNodeMetadataParams{ID: nodeID, Metadata: metadata}); err != nil {
return s.error(ctx, err, "UpdateWorkspaceNodeMetadata", nil)
}
return nil
}
func (s *PFStore) RefreshNodeMetadata(ctx context.Context, nodeID int64) (model.WorkspaceNode, error) {
node, err := s.roqueries.GetWorkspaceNode(ctx, nodeID)
if err != nil {
return model.WorkspaceNode{}, s.error(ctx, err, "RefreshNodeMetadata", nil)
}
var meta json.RawMessage
switch model.NodeType(node.NodeType) {
case model.NodeTypePatent:
patent, err := s.roqueries.GetPatent(ctx, node.EntityID)
if err != nil {
return model.WorkspaceNode{}, s.error(ctx, err, "RefreshNodeMetadata", nil)
}
meta = marshalNodeMeta(patentNodeMeta{
Name: patent.Name,
PubDate: patent.PubDate,
PriorityDate: patent.PriorityDate,
DeadlineDate: patent.PriorityDate.AddDate(0, 33, 0),
})
case model.NodeTypeApplicant:
applicant, err := s.roqueries.GetApplicant(ctx, node.EntityID)
if err != nil {
return model.WorkspaceNode{}, s.error(ctx, err, "RefreshNodeMetadata", nil)
}
meta = marshalNodeMeta(applicantNodeMeta{
Name: applicant.Name,
Address: applicant.Address,
Postcode: applicant.Postcode,
Country: applicant.Country,
})
case model.NodeTypeAgent:
agent, err := s.roqueries.GetAgent(ctx, node.EntityID)
if err != nil {
return model.WorkspaceNode{}, s.error(ctx, err, "RefreshNodeMetadata", nil)
}
meta = marshalNodeMeta(agentNodeMeta{
Name: agent.Name,
Email: agent.Email,
Address: agent.Address,
Postcode: agent.Postcode,
Country: agent.Country,
})
default:
return model.WorkspaceNode{}, fmt.Errorf("node type %q does not support metadata refresh", node.NodeType)
}
if err := s.rwqueries.UpdateWorkspaceNodeMetadata(ctx, sqlcgen.UpdateWorkspaceNodeMetadataParams{
ID: nodeID,
Metadata: meta,
}); err != nil {
return model.WorkspaceNode{}, s.error(ctx, err, "RefreshNodeMetadata", nil)
}
updated, err := s.roqueries.GetWorkspaceNode(ctx, nodeID)
if err != nil {
return model.WorkspaceNode{}, s.error(ctx, err, "RefreshNodeMetadata", nil)
}
return mapper.WorkspaceNodeFromRow(updated), nil
}
func (s *PFStore) deleteWorkspaceNode(ctx context.Context, q Queries, nodeID int64) error {
if err := q.DeleteWorkspaceEdgesByNode(ctx, nodeID); err != nil {
return err
}
if err := q.DeleteWorkspaceNode(ctx, nodeID); err != nil {
return err
}
return nil
}
func (s *PFStore) DeleteWorkspaceNode(ctx context.Context, nodeID int64) error {
err := s.execTx(ctx, func(q Queries) error {
return s.deleteWorkspaceNode(ctx, q, nodeID)
})
if err != nil {
return s.error(ctx, err, "DeleteWorkspaceNode", nil)
}
return nil
}
func (s *PFStore) moveWorkspaceNode(ctx context.Context, q Queries, nodeID int64, targetWorkspaceID int32) error {
node, err := q.MoveWorkspaceNode(ctx, sqlcgen.MoveWorkspaceNodeParams{
ID: nodeID,
WorkspaceID: targetWorkspaceID,
})
if err != nil {
return err
}
_, err = connectWithExisting(ctx, q, mapper.WorkspaceNodeFromRow(node))
return err
}
func (s *PFStore) MoveWorkspaceNode(ctx context.Context, nodeID int64, targetWorkspaceID int32) error {
err := s.execTx(ctx, func(q Queries) error {
return s.moveWorkspaceNode(ctx, q, nodeID, targetWorkspaceID)
})
if err != nil {
return s.error(ctx, err, "MoveWorkspaceNode", nil)
}
return nil
}
func (s *PFStore) AddWorkspaceEdge(ctx context.Context, workspaceID int32, sourceID, targetID int64, edgeType model.EdgeType) (model.WorkspaceEdge, error) {
existing, err := s.roqueries.GetWorkspaceEdge(ctx, sqlcgen.GetWorkspaceEdgeParams{
WorkspaceID: workspaceID,
NodeID1: sourceID,
NodeID2: targetID,
})
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return model.WorkspaceEdge{}, s.error(ctx, err, "AddWorkspaceEdge", nil)
}
if existing.ID != 0 {
err = fmt.Errorf("edge already exists")
return model.WorkspaceEdge{}, s.error(ctx, err, "AddWorkspaceEdge", nil)
}
row, err := s.rwqueries.CreateOrGetWorkspaceEdge(ctx, sqlcgen.CreateOrGetWorkspaceEdgeParams{
WorkspaceID: workspaceID,
NodeID1: sourceID,
NodeID2: targetID,
EdgeType: string(edgeType),
})
if err != nil {
return model.WorkspaceEdge{}, s.error(ctx, err, "AddWorkspaceEdge", nil)
}
return mapper.WorkspaceEdgeFromRow(row), nil
}
func (s *PFStore) GetWorkspaceEdgesByWorkspace(ctx context.Context, workspaceID int32) ([]model.WorkspaceEdge, error) {
rows, err := s.roqueries.GetWorkspaceEdgesByWorkspace(ctx, workspaceID)
if err != nil {
return nil, s.error(ctx, err, "GetWorkspaceEdgesByWorkspace", nil)
}
out := make([]model.WorkspaceEdge, len(rows))
for i, r := range rows {
out[i] = mapper.WorkspaceEdgeFromRow(r)
}
return out, nil
}
func (s *PFStore) DeleteWorkspaceEdge(ctx context.Context, edgeID int64) error {
if err := s.rwqueries.DeleteWorkspaceEdge(ctx, edgeID); err != nil {
return s.error(ctx, err, "DeleteWorkspaceEdge", nil)
}
return nil
}
func (s *PFStore) GetWorkspaceGraph(ctx context.Context, workspaceID int32) (model.WorkspaceGraph, error) {
ws, err := s.GetWorkspace(ctx, workspaceID)
if err != nil {
return model.WorkspaceGraph{}, err
}
nodes, err := s.GetWorkspaceNodesByWorkspace(ctx, workspaceID)
if err != nil {
return model.WorkspaceGraph{}, err
}
edges, err := s.GetWorkspaceEdgesByWorkspace(ctx, workspaceID)
if err != nil {
return model.WorkspaceGraph{}, err
}
return model.WorkspaceGraph{
Workspace: ws,
Nodes: nodes,
Edges: edges,
}, nil
}
type AddPatentNodeParams struct {
WorkspaceID int32
PatentID int64
PosX int32
PosY int32
Width int32
Height int32
}
func (s *PFStore) addPatentNodeTx(ctx context.Context, q Queries, p AddPatentNodeParams) (model.WorkspaceGraph, error) {
var graph model.WorkspaceGraph
patent, err := q.GetPatent(ctx, p.PatentID)
if err != nil {
return model.WorkspaceGraph{}, err
}
applicant, err := q.GetApplicant(ctx, patent.ApplicantID)
if err != nil {
return model.WorkspaceGraph{}, err
}
agents, err := q.GetAgentsByPatent(ctx, p.PatentID)
if err != nil {
return model.WorkspaceGraph{}, err
}
w, h := p.Width, p.Height
if w == 0 {
w = defaultNodeWidth
}
if h == 0 {
h = defaultNodeHeight
}
patNode, err := q.CreateOrGetWorkspaceNode(ctx, sqlcgen.CreateOrGetWorkspaceNodeParams{
WorkspaceID: p.WorkspaceID,
NodeType: string(model.NodeTypePatent),
EntityID: p.PatentID,
PosX: p.PosX,
PosY: p.PosY,
Width: p.Width,
Height: p.Height,
Metadata: marshalNodeMeta(patentNodeMeta{
Name: patent.Name,
PubDate: patent.PubDate,
PriorityDate: patent.PriorityDate,
DeadlineDate: patent.PriorityDate.AddDate(0, 33, 0),
}),
})
if err != nil {
return model.WorkspaceGraph{}, err
}
graph.Nodes = append(graph.Nodes, mapper.WorkspaceNodeFromRow(patNode))
appY := (patNode.PosY + patNode.Height) + 2
appNode, err := q.CreateOrGetWorkspaceNode(ctx, sqlcgen.CreateOrGetWorkspaceNodeParams{
WorkspaceID: p.WorkspaceID,
NodeType: string(model.NodeTypeApplicant),
EntityID: applicant.ID,
PosX: patNode.PosX,
PosY: appY,
Width: w,
Height: h,
Metadata: marshalNodeMeta(applicantNodeMeta{
Name: applicant.Name,
Address: applicant.Address,
Postcode: applicant.Postcode,
Country: applicant.Country,
}),
})
if err != nil {
return model.WorkspaceGraph{}, err
}
graph.Nodes = append(graph.Nodes, mapper.WorkspaceNodeFromRow(appNode))
appEdge, err := q.CreateOrGetWorkspaceEdge(ctx, sqlcgen.CreateOrGetWorkspaceEdgeParams{
WorkspaceID: p.WorkspaceID,
NodeID1: patNode.ID,
NodeID2: appNode.ID,
EdgeType: string(model.EdgeTypeSystem),
})
if err != nil {
return model.WorkspaceGraph{}, err
}
graph.Edges = append(graph.Edges, mapper.WorkspaceEdgeFromRow(appEdge))
agY := (appNode.PosY + appNode.Height) + 2
for _, agent := range agents {
agNode, err := q.CreateOrGetWorkspaceNode(ctx, sqlcgen.CreateOrGetWorkspaceNodeParams{
WorkspaceID: p.WorkspaceID,
NodeType: string(model.NodeTypeAgent),
EntityID: agent.ID,
PosX: patNode.PosX,
PosY: agY,
Width: w,
Height: h,
Metadata: marshalNodeMeta(agentNodeMeta{
Name: agent.Name,
Email: agent.Email,
Address: agent.Address,
Postcode: agent.Postcode,
Country: agent.Country,
}),
})
if err != nil {
return model.WorkspaceGraph{}, err
}
graph.Nodes = append(graph.Nodes, mapper.WorkspaceNodeFromRow(agNode))
agEdge, err := q.CreateOrGetWorkspaceEdge(ctx, sqlcgen.CreateOrGetWorkspaceEdgeParams{
WorkspaceID: p.WorkspaceID,
NodeID1: patNode.ID,
NodeID2: agNode.ID,
EdgeType: string(model.EdgeTypeSystem),
})
if err != nil {
return model.WorkspaceGraph{}, err
}
graph.Edges = append(graph.Edges, mapper.WorkspaceEdgeFromRow(agEdge))
agY = (agNode.PosY + agNode.Height) + 2
}
return graph, nil
}
func (s *PFStore) AddPatentNodeTx(ctx context.Context, p AddPatentNodeParams) (model.WorkspaceGraph, error) {
var graph model.WorkspaceGraph
err := s.execTx(ctx, func(q Queries) error {
var err error
graph, err = s.addPatentNodeTx(ctx, q, p)
return err
})
return graph, err
}
type AddApplicantNodeParams struct {
WorkspaceID int32
ApplicantID int64
PosX int32
PosY int32
Width int32
Height int32
}
func (s *PFStore) addApplicantNodeTx(ctx context.Context, q Queries, p AddApplicantNodeParams) (model.WorkspaceGraph, error) {
var graph model.WorkspaceGraph
applicant, err := q.GetApplicant(ctx, p.ApplicantID)
if err != nil {
return model.WorkspaceGraph{}, err
}
w, h := p.Width, p.Height
if w == 0 {
w = defaultNodeWidth
}
if h == 0 {
h = defaultNodeHeight
}
appNode, err := q.CreateOrGetWorkspaceNode(ctx, sqlcgen.CreateOrGetWorkspaceNodeParams{
WorkspaceID: p.WorkspaceID,
NodeType: string(model.NodeTypeApplicant),
EntityID: p.ApplicantID,
PosX: p.PosX,
PosY: p.PosY,
Width: w,
Height: h,
Metadata: marshalNodeMeta(applicantNodeMeta{
Name: applicant.Name,
Address: applicant.Address,
Postcode: applicant.Postcode,
Country: applicant.Country,
}),
})
if err != nil {
return model.WorkspaceGraph{}, err
}
graph.Nodes = append(graph.Nodes, mapper.WorkspaceNodeFromRow(appNode))
patents, err := q.GetPatentsByApplicant(ctx, p.ApplicantID)
if err != nil {
return model.WorkspaceGraph{}, err
}
for _, patent := range patents {
edge, err := connectIfNodeExists(
ctx,
q,
p.WorkspaceID,
appNode.ID,
model.NodeTypePatent,
patent.ID,
model.EdgeTypeSystem,
)
if err != nil {
return model.WorkspaceGraph{}, err
}
if edge.ID != 0 {
graph.Edges = append(graph.Edges, edge)
}
}
return graph, nil
}
func (s *PFStore) AddApplicantNodeTx(ctx context.Context, p AddApplicantNodeParams) (model.WorkspaceGraph, error) {
var graph model.WorkspaceGraph
err := s.execTx(ctx, func(q Queries) error {
var err error
graph, err = s.addApplicantNodeTx(ctx, q, p)
return err
})
return graph, err
}
type AddAgentNodeParams struct {
WorkspaceID int32
AgentID int64
PosX int32
PosY int32
Width int32
Height int32
}
func (s *PFStore) addAgentNodeTx(ctx context.Context, q Queries, p AddAgentNodeParams) (model.WorkspaceGraph, error) {
var graph model.WorkspaceGraph
agent, err := q.GetAgent(ctx, p.AgentID)
if err != nil {
return model.WorkspaceGraph{}, err
}
w, h := p.Width, p.Height
if w == 0 {
w = defaultNodeWidth
}
if h == 0 {
h = defaultNodeHeight
}
agNode, err := q.CreateOrGetWorkspaceNode(ctx, sqlcgen.CreateOrGetWorkspaceNodeParams{
WorkspaceID: p.WorkspaceID,
NodeType: string(model.NodeTypeAgent),
EntityID: p.AgentID,
PosX: p.PosX,
PosY: p.PosY,
Width: w,
Height: h,
Metadata: marshalNodeMeta(agentNodeMeta{
Name: agent.Name,
Email: agent.Email,
Address: agent.Address,
Postcode: agent.Postcode,
Country: agent.Country,
}),
})
if err != nil {
return model.WorkspaceGraph{}, err
}
graph.Nodes = append(graph.Nodes, mapper.WorkspaceNodeFromRow(agNode))
patents, err := q.GetPatentsByAgent(ctx, p.AgentID)
if err != nil {
return model.WorkspaceGraph{}, err
}
for _, patent := range patents {
edge, err := connectIfNodeExists(
ctx,
q,
p.WorkspaceID,
agNode.ID,
model.NodeTypePatent,
patent.ID,
model.EdgeTypeSystem,
)
if err != nil {
return model.WorkspaceGraph{}, err
}
if edge.ID != 0 {
graph.Edges = append(graph.Edges, edge)
}
}
return graph, nil
}
func (s *PFStore) AddAgentNodeTx(ctx context.Context, p AddAgentNodeParams) (model.WorkspaceGraph, error) {
var graph model.WorkspaceGraph
err := s.execTx(ctx, func(q Queries) error {
var err error
graph, err = s.addAgentNodeTx(ctx, q, p)
return err
})
return graph, err
}
func (s *PFStore) bulkDeleteNodes(ctx context.Context, q Queries, workspaceID int32, nodeIDs []int64) error {
nodeRows, err := q.GetWorkspaceNodesByWorkspace(ctx, workspaceID)
if err != nil {
return err
}
nodes := make([]model.WorkspaceNode, len(nodeRows))
for i, r := range nodeRows {
nodes[i] = mapper.WorkspaceNodeFromRow(r)
}
if err := verifyNodesBelongToWorkspace(nodes, workspaceID, nodeIDs); err != nil {
return err
}
sel := idSet(nodeIDs)
edges, err := q.GetWorkspaceEdgesByWorkspace(ctx, workspaceID)
if err != nil {
return err
}
for _, e := range edges {
_, node1Sel := sel[e.NodeID1]
_, node2Sel := sel[e.NodeID2]
if node1Sel || node2Sel {
if err := q.DeleteWorkspaceEdge(ctx, e.ID); err != nil {
return err
}
}
}
for _, nodeID := range nodeIDs {
if err := q.DeleteWorkspaceNode(ctx, nodeID); err != nil {
return err
}
}
return nil
}
func (s *PFStore) BulkDeleteNodes(ctx context.Context, workspaceID int32, nodeIDs []int64) error {
return s.execTx(ctx, func(q Queries) error {
return s.bulkDeleteNodes(ctx, q, workspaceID, nodeIDs)
})
}
func (s *PFStore) bulkMoveNodes(ctx context.Context, q Queries, workspaceID int32, nodeIDs []int64, targetWorkspaceID int32) error {
nodeRows, err := q.GetWorkspaceNodesByWorkspace(ctx, workspaceID)
if err != nil {
return err
}
nodes := make([]model.WorkspaceNode, len(nodeRows))
for i, r := range nodeRows {
nodes[i] = mapper.WorkspaceNodeFromRow(r)
}
if err := verifyNodesBelongToWorkspace(nodes, workspaceID, nodeIDs); err != nil {
return err
}
sel := idSet(nodeIDs)
edges, err := q.GetWorkspaceEdgesByWorkspace(ctx, workspaceID)
if err != nil {
return err
}
for _, e := range edges {
_, node1Sel := sel[e.NodeID1]
_, node2Sel := sel[e.NodeID2]
if !node1Sel && !node2Sel {
continue
}
if err := q.DeleteWorkspaceEdge(ctx, e.ID); err != nil {
return err
}
if node1Sel && node2Sel {
if _, err := q.CreateOrGetWorkspaceEdge(ctx, sqlcgen.CreateOrGetWorkspaceEdgeParams{
WorkspaceID: targetWorkspaceID,
NodeID1: e.NodeID1,
NodeID2: e.NodeID2,
EdgeType: e.EdgeType,
}); err != nil {
return err
}
}
}
for _, nodeID := range nodeIDs {
row, err := q.MoveWorkspaceNode(ctx, sqlcgen.MoveWorkspaceNodeParams{
ID: nodeID,
WorkspaceID: targetWorkspaceID,
})
if err != nil {
return err
}
_, err = connectWithExisting(ctx, q, mapper.WorkspaceNodeFromRow(row))
if err != nil {
return err
}
}
return nil
}
func (s *PFStore) BulkMoveNodes(ctx context.Context, workspaceID int32, nodeIDs []int64, targetWorkspaceID int32) error {
return s.execTx(ctx, func(q Queries) error {
return s.bulkMoveNodes(ctx, q, workspaceID, nodeIDs, targetWorkspaceID)
})
}
func (s *PFStore) bulkCopyNodes(ctx context.Context, q Queries, workspaceID int32, nodeIDs []int64, targetWorkspaceID int32) (model.WorkspaceGraph, error) {
var graph model.WorkspaceGraph
srcRows, err := q.GetWorkspaceNodesByWorkspace(ctx, workspaceID)
if err != nil {
return model.WorkspaceGraph{}, err
}
srcNodes := make([]model.WorkspaceNode, len(srcRows))
for i, r := range srcRows {
srcNodes[i] = mapper.WorkspaceNodeFromRow(r)
}
if err := verifyNodesBelongToWorkspace(srcNodes, workspaceID, nodeIDs); err != nil {
return model.WorkspaceGraph{}, err
}
srcByID := make(map[int64]model.WorkspaceNode, len(srcNodes))
for _, n := range srcNodes {
srcByID[n.ID] = n
}
sel := idSet(nodeIDs)
oldToNew := make(map[int64]int64, len(nodeIDs))
copiedNodes := make([]model.WorkspaceNode, 0, len(nodeIDs))
for _, nodeID := range nodeIDs {
src, ok := srcByID[nodeID]
if !ok {
return model.WorkspaceGraph{}, fmt.Errorf("invalid ID provided, could not find in set of existing")
}
newRow, err := q.CreateOrGetWorkspaceNode(ctx, sqlcgen.CreateOrGetWorkspaceNodeParams{
WorkspaceID: targetWorkspaceID,
NodeType: string(src.NodeType),
EntityID: src.EntityID,
PosX: src.PosX,
PosY: src.PosY,
Width: src.Width,
Height: src.Height,
Metadata: src.Metadata,
})
if err != nil {
return model.WorkspaceGraph{}, fmt.Errorf("copy node %d: %w", nodeID, err)
}
node := mapper.WorkspaceNodeFromRow(newRow)
graph.Nodes = append(graph.Nodes, node)
copiedNodes = append(copiedNodes, node)
oldToNew[nodeID] = newRow.ID
}
srcEdges, err := q.GetWorkspaceEdgesByWorkspace(ctx, workspaceID)
if err != nil {
return model.WorkspaceGraph{}, err
}
for _, e := range srcEdges {
_, node1Sel := sel[e.NodeID1]
_, node2Sel := sel[e.NodeID2]
if !node1Sel || !node2Sel {
continue
}
edgeRow, err := q.CreateOrGetWorkspaceEdge(ctx, sqlcgen.CreateOrGetWorkspaceEdgeParams{
WorkspaceID: targetWorkspaceID,
NodeID1: oldToNew[e.NodeID1],
NodeID2: oldToNew[e.NodeID2],
EdgeType: e.EdgeType,
})
if err != nil {
return model.WorkspaceGraph{}, fmt.Errorf("copy edge %d: %w", e.ID, err)
}
graph.Edges = append(graph.Edges, mapper.WorkspaceEdgeFromRow(edgeRow))
}
edgeIDs := make([]int64, len(graph.Edges))
for i, e := range graph.Edges {
edgeIDs[i] = e.ID
}
appendedEdges := idSet(edgeIDs)
for _, node := range copiedNodes {
edges, err := connectWithExisting(ctx, q, node)
if err != nil {
return model.WorkspaceGraph{}, err
}
for _, e := range edges {
if _, exists := appendedEdges[e.ID]; !exists {
appendedEdges[e.ID] = struct{}{}
graph.Edges = append(graph.Edges, e)
}
}
}
return graph, nil
}
func (s *PFStore) BulkCopyNodes(ctx context.Context, workspaceID int32, nodeIDs []int64, targetWorkspaceID int32) (model.WorkspaceGraph, error) {
var graph model.WorkspaceGraph
err := s.execTx(ctx, func(q Queries) error {
var err error
graph, err = s.bulkCopyNodes(ctx, q, workspaceID, nodeIDs, targetWorkspaceID)
return err
})
return graph, err
}
func (s *PFStore) bulkUpdateNodePositions(ctx context.Context, q Queries, workspaceID int32, updates []model.UpdateNodeBoundsParams) error {
nodeRows, err := q.GetWorkspaceNodesByWorkspace(ctx, workspaceID)
if err != nil {
return err
}
nodes := make([]model.WorkspaceNode, len(nodeRows))
for i, r := range nodeRows {
nodes[i] = mapper.WorkspaceNodeFromRow(r)
}
ids := make([]int64, len(updates))
for i, u := range updates {
ids[i] = u.NodeID
}
if err := verifyNodesBelongToWorkspace(nodes, workspaceID, ids); err != nil {
return err
}
for _, u := range updates {
if err := q.UpdateWorkspaceNodePosition(ctx, sqlcgen.UpdateWorkspaceNodePositionParams{
ID: u.NodeID,
PosX: u.PosX,
PosY: u.PosY,
Width: u.Width,
Height: u.Height,
}); err != nil {
return fmt.Errorf("update node %d position: %w", u.NodeID, err)
}
}
return nil
}
func (s *PFStore) BulkUpdateNodePositions(ctx context.Context, workspaceID int32, updates []model.UpdateNodeBoundsParams) error {
return s.execTx(ctx, func(q Queries) error {
return s.bulkUpdateNodePositions(ctx, q, workspaceID, updates)
})
}
func idSet(ids []int64) map[int64]struct{} {
s := make(map[int64]struct{}, len(ids))
for _, id := range ids {
s[id] = struct{}{}
}
return s
}
func verifyNodesBelongToWorkspace(nodes []model.WorkspaceNode, workspaceID int32, nodeIDs []int64) error {
inWS := make(map[int64]struct{}, len(nodes))
for _, n := range nodes {
inWS[n.ID] = struct{}{}
}
for _, id := range nodeIDs {
if _, ok := inWS[id]; !ok {
return fmt.Errorf("node %d does not belong to workspace %d", id, workspaceID)
}
}
return nil
}
// Used to connect entity nodes based on FKs when changing their workspace. Must be used in tx
func connectWithExisting(ctx context.Context, q Queries, node model.WorkspaceNode) ([]model.WorkspaceEdge, error) {
var edges []model.WorkspaceEdge
switch model.NodeType(node.NodeType) {
case model.NodeTypePatent:
patent, err := q.GetPatent(ctx, node.EntityID)
if err != nil {
return nil, err
}
applicant, err := q.GetApplicant(ctx, patent.ApplicantID)
if err != nil {
return nil, err
}
agents, err := q.GetAgentsByPatent(ctx, patent.ID)
if err != nil {
return nil, err
}
applEdge, err := connectIfNodeExists(
ctx,
q,
node.WorkspaceID,
node.ID,
model.NodeTypeApplicant,
applicant.ID,
model.EdgeTypeSystem,
)
if err != nil {
return nil, err
}
if applEdge.ID != 0 {
edges = append(edges, applEdge)
}
for _, agent := range agents {
agEdge, err := connectIfNodeExists(
ctx,
q,
node.WorkspaceID,
node.ID,
model.NodeTypeAgent,
agent.ID,
model.EdgeTypeSystem,
)
if err != nil {
return nil, err
}
if agEdge.ID != 0 {
edges = append(edges, agEdge)
}
}
case model.NodeTypeApplicant:
patents, err := q.GetPatentsByApplicant(ctx, node.EntityID)
if err != nil {
return nil, err
}
for _, patent := range patents {
patEdge, err := connectIfNodeExists(
ctx,
q,
node.WorkspaceID,
node.ID,
model.NodeTypePatent,
patent.ID,
model.EdgeTypeSystem,
)
if err != nil {
return nil, err
}
if patEdge.ID != 0 {
edges = append(edges, patEdge)
}
}
case model.NodeTypeAgent:
patents, err := q.GetPatentsByAgent(ctx, node.EntityID)
if err != nil {
return nil, err
}
for _, patent := range patents {
patEdge, err := connectIfNodeExists(
ctx,
q,
node.WorkspaceID,
node.ID,
model.NodeTypePatent,
patent.ID,
model.EdgeTypeSystem,
)
if err != nil {
return nil, err
}
if patEdge.ID != 0 {
edges = append(edges, patEdge)
}
}
default:
}
return edges, nil
}
func connectIfNodeExists(
ctx context.Context,
q Queries,
workspaceID int32,
sourceNodeID int64,
targetType model.NodeType,
targetEntityID int64,
edgeType model.EdgeType,
) (model.WorkspaceEdge, error) {
targetNode, err := q.GetWorkspaceNodeByEntity(ctx, sqlcgen.GetWorkspaceNodeByEntityParams{
WorkspaceID: workspaceID,
NodeType: string(targetType),
EntityID: targetEntityID,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return model.WorkspaceEdge{}, nil
}
return model.WorkspaceEdge{}, err
}
edge, err := q.CreateOrGetWorkspaceEdge(ctx, sqlcgen.CreateOrGetWorkspaceEdgeParams{
WorkspaceID: workspaceID,
NodeID1: sourceNodeID,
NodeID2: targetNode.ID,
EdgeType: string(edgeType),
})
if err != nil {
return model.WorkspaceEdge{}, err
}
return mapper.WorkspaceEdgeFromRow(edge), nil
}
package clienthelper
import (
"errors"
"fmt"
"io"
"net/http"
"net/http/cookiejar"
"net/url"
"strings"
"time"
"codeberg.org/at375/patent-flow/util/uagenerator"
"golang.org/x/net/html"
)
type Client struct {
Client *http.Client
ViewState string
UserAgent string
}
/*
Returns *http.Client with required state (session cookie, proxy) and ViewState
to use in Header for subsequent partial requests
*/
func InitClient(baseUrl string, proxy string) (*Client, error) {
if strings.TrimSpace(proxy) == "" {
return nil, fmt.Errorf("failed to parse proxy: proxy is empty string")
}
proxyURL, err := url.Parse(proxy)
if err != nil {
return nil, fmt.Errorf("failed to parse proxy: %v", err.Error())
}
userAgent := uagenerator.GetRandom()
transport := &http.Transport{
Proxy: http.ProxyURL(proxyURL),
DisableKeepAlives: false,
MaxIdleConns: 0,
MaxIdleConnsPerHost: 0,
}
jar, _ := cookiejar.New(nil)
client := &http.Client{
Transport: transport,
Jar: jar,
Timeout: 15 * time.Second,
}
req, _ := http.NewRequest("GET", baseUrl, nil)
req.Header.Set("User-Agent", userAgent)
req.Header.Set("Accept", "text/html,application/xhtml+xml")
req.Header.Set("Accept-Language", "en-US,en;q=0.9")
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
u := req.URL
cookies := client.Jar.Cookies(u)
var cookieJSESSIONID string
for _, c := range cookies {
if c.Name == "JSESSIONID" {
cookieJSESSIONID = c.Value
}
}
if cookieJSESSIONID == "" {
return nil, errors.New("failed to get session cookie")
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
viewState, err := extractViewState(string(body))
if err != nil {
return nil, err
}
return &Client{client, viewState, userAgent}, nil
}
func extractViewState(htmlContent string) (string, error) {
doc, err := html.Parse(strings.NewReader(htmlContent))
if err != nil {
return "", err
}
var viewState string
var f func(*html.Node)
f = func(n *html.Node) {
if n.Type == html.ElementNode && n.Data == "input" {
var name, value string
for _, attr := range n.Attr {
if attr.Key == "name" && attr.Val == "javax.faces.ViewState" {
name = attr.Val
}
if attr.Key == "value" {
value = attr.Val
}
}
if name == "javax.faces.ViewState" {
viewState = value
return
}
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
f(c)
}
}
f(doc)
if viewState == "" {
return "", errors.New("ViewState not found")
}
return viewState, nil
}
package gazettehelper
import (
"encoding/xml"
"errors"
"io"
"net/http"
"net/url"
"regexp"
"strings"
"codeberg.org/at375/patent-flow/internal/util/clienthelper"
"golang.org/x/net/html"
)
var baseUrl string = "https://patentscope.wipo.int/search/en/pctBrowse.jsf"
var GazetteRegex = regexp.MustCompile(`^[0-9]{2}/(19|20)[0-9]{2}$`) // capture "dd/dddd"
type GazetteFetcherFunc func(year string, proxy string) ([]ParsedGazette, error)
type ParsedGazette struct {
Name string `json:"name"`
Date string `json:"date"`
Count string `json:"count"`
}
type PartialResponse struct {
ID string `xml:"id,attr"`
Changes Changes `xml:"changes"`
}
type Changes struct {
Updates []Update `xml:"update"`
}
type Update struct {
ID string `xml:"id,attr"`
Content string `xml:",chardata"`
}
func FetchGazettesByYear(year string, proxy string) ([]ParsedGazette, error) {
u := baseUrl + "?currYear=" + year
client, err := clienthelper.InitClient(u, proxy)
if err != nil {
return nil, err
}
data := url.Values{
"javax.faces.partial.ajax": {"true"},
"javax.faces.source": {"pctBrowseForm:results_panel"},
"primefaces.ignoreautoupdate": {"true"},
"javax.faces.partial.execute": {"pctBrowseForm:results_panel"},
"javax.faces.partial.render": {"pctBrowseForm:results_panel"},
"pctBrowseForm:results_panel": {"pctBrowseForm:results_panel"},
"pctBrowseForm:results_panel_load": {"true"},
"pctBrowseForm": {"pctBrowseForm"},
"pctBrowseForm:currYear:input": {year},
"javax.faces.ViewState": {client.ViewState},
}
req, err := http.NewRequest(
"POST",
u,
strings.NewReader(data.Encode()),
)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Origin", "https://patentscope.wipo.int")
req.Header.Set("Referer", baseUrl+"?currYear="+year)
req.Header.Set("User-Agent", client.UserAgent)
req.Header.Set("X-Requested-With", "XMLHttpRequest")
resp, err := client.Client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var pr PartialResponse
err = xml.Unmarshal(body, &pr)
if err != nil {
return nil, err
}
var htmlString string
for _, update := range pr.Changes.Updates {
if update.ID == "pctBrowseForm:results_panel" {
htmlString = update.Content
break
}
}
if htmlString == "" {
return nil, errors.New("could not find result panel")
}
doc, err := html.Parse(strings.NewReader(htmlString))
if err != nil {
return nil, err
}
cells := findGridCells(doc)
if len(cells)%4 != 0 {
return nil, errors.New("unexpected number of gridcells")
}
var gazettes []ParsedGazette
for i := 0; i < len(cells); i += 4 {
name := strings.TrimSpace(cells[i].FirstChild.Data)
date := strings.TrimSpace(cells[i+1].FirstChild.Data)
count := strings.TrimSpace(cells[i+2].FirstChild.Data)
gazettes = append(gazettes, ParsedGazette{Name: name, Date: date, Count: count})
}
return gazettes, nil
}
func findGridCells(n *html.Node) []*html.Node {
var cells []*html.Node
if n.Type == html.ElementNode && n.Data == "td" {
for _, a := range n.Attr {
if a.Key == "role" && a.Val == "gridcell" {
cells = append(cells, n)
break
}
}
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
cells = append(cells, findGridCells(c)...)
}
return cells
}
package paturlhelper
import (
"encoding/csv"
"errors"
"io"
"net/http"
"net/url"
"regexp"
"strings"
"codeberg.org/at375/patent-flow/internal/util/clienthelper"
)
var baseUrl string = "https://patentscope.wipo.int/search/en/pctGazetteBrowse.jsf?currGazette="
var numRe = regexp.MustCompile(`[A-Z]{2}/\d{4}/\d+`) // capture "LL/dddd/d...d"
type PatentUrlFetcherFunc func(gazetteName string, proxy string) ([]ParsedPatentURL, error)
// TODO: move types like this to internal/types package
type ParsedPatentURL struct {
URL string
Name string
}
func FetchPatentURLsByGazette(gazetteName string, proxy string) ([]ParsedPatentURL, error) {
fetchUrl := baseUrl + gazetteName
client, err := clienthelper.InitClient(fetchUrl, proxy)
if err != nil {
return nil, err
}
data := url.Values{
"weeklyPublicationListForm": {"weeklyPublicationListForm"},
"weeklyPublicationListForm:resultTable:globalFilter": {""},
"weeklyPublicationListForm:resultTable:j_idt1256:filter": {""},
"weeklyPublicationListForm:resultTable:j_idt1258:filter": {""},
"weeklyPublicationListForm:resultTable:j_idt1260:filter": {""},
"weeklyPublicationListForm:resultTable:j_idt1262:filter": {""},
"weeklyPublicationListForm:resultTable:j_idt1264:filter": {""},
"weeklyPublicationListForm:resultTable:j_idt1266:filter": {""},
"javax.faces.ViewState": {client.ViewState},
"weeklyPublicationListForm:j_idt1250": {"weeklyPublicationListForm:j_idt1250"},
}
req, err := http.NewRequest(
"POST",
fetchUrl,
strings.NewReader(data.Encode()),
)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Origin", "https://patentscope.wipo.int")
req.Header.Set("Referer", fetchUrl)
req.Header.Set("User-Agent", client.UserAgent)
// req.Header.Set("X-Requested-With", "XMLHttpRequest")
resp, err := client.Client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
patentUrls, errs := parseContent(resp.Body)
if len(errs) > 0 {
return patentUrls, errors.Join(errs...)
}
return patentUrls, nil
}
func parseContent(r io.Reader) ([]ParsedPatentURL, []error) {
cr := csv.NewReader(r)
cr.FieldsPerRecord = 6
res := make([]ParsedPatentURL, 0, 8192)
errs := []error{}
if _, err := cr.Read(); err != nil {
if err == io.EOF {
errs = append(errs, errors.New("no data found in CSV"))
} else {
errs = append(errs, err)
}
return nil, errs
}
for {
rec, err := cr.Read()
if err == io.EOF {
break
}
if err != nil {
errs = append(errs, err)
continue
}
num := rec[0]
name := rec[1]
numFound := numRe.FindString(num)
if numFound == "" {
errs = append(errs, errors.New("could not parse patent number from: "+num))
continue
}
n := strings.ReplaceAll(numFound, "/", "")
u := "https://patentscope.wipo.int/search/en/detail.jsf?docId=" + n
res = append(res, ParsedPatentURL{URL: u, Name: name})
}
if len(res) == 0 {
res = nil
errs = append(errs, errors.New("no data found in CSV"))
}
return res, errs
}
package logger
import (
"fmt"
"os"
"strconv"
_ "github.com/joho/godotenv/autoload"
)
type Config struct {
StdoutLoggingLevel int
FileLoggingLevel int
LogDirPath string
DBLoggingLevel int
DBLogsTable string
KafkaLoggingLevel int
}
func LoadConfig() (Config, error) {
sll, err := strconv.Atoi(getEnv("STDOUT_LOGGING_LEVEL", "0"))
if err != nil {
return Config{}, fmt.Errorf("can't convert value of STDOUT_LOGGING_LEVEL to int, check .env")
}
fll, err := strconv.Atoi(getEnv("FILE_LOGGING_LEVEL", "1"))
if err != nil {
return Config{}, fmt.Errorf("can't convert value of FILE_LOGGING_LEVEL to int, check .env")
}
dbll, err := strconv.Atoi(getEnv("DB_LOGGING_LEVEL", "3"))
if err != nil {
return Config{}, fmt.Errorf("can't convert value of DB_LOGGING_LEVEL to int, check .env")
}
kll, err := strconv.Atoi(getEnv("KAFKA_LOGGING_LEVEL", "3"))
if err != nil {
return Config{}, fmt.Errorf("can't convert value of KAFKA_LOGGING_LEVEL to int, check .env")
}
return Config{
StdoutLoggingLevel: sll,
FileLoggingLevel: fll,
DBLoggingLevel: dbll,
KafkaLoggingLevel: kll,
LogDirPath: getEnv("LOG_DIR_PATH", "logs"),
DBLogsTable: getEnv("DB_LOGS_TABLE", "logs"),
}, nil
}
func getEnv(key, defaultVal string) string {
if val := os.Getenv(key); val != "" {
return val
}
return defaultVal
}
package logger
import (
"context"
"encoding/json"
"fmt"
"github.com/jackc/pgx/v5/pgxpool"
)
// DBSink writes log events into a SQL table.
//
// Expected table schema (PostgreSQL example):
//
// CREATE TABLE logs (
// id BIGSERIAL PRIMARY KEY,
// level TEXT NOT NULL,
// message TEXT NOT NULL,
// data JSONB,
// trace_id TEXT,
// created_at TIMESTAMPTZ NOT NULL DEFAULT now()
// );
type DBSink struct {
name string
db *pgxpool.Pool
table string
minLevel LoggingLevel
}
// The caller owns db and is responsible for closing it; DBSink.Close is a no-op.
func NewDBSink(db *pgxpool.Pool, cfg Config) (*DBSink, error) {
l := cfg.DBLoggingLevel
return &DBSink{name: "dbsink", db: db, table: cfg.DBLogsTable, minLevel: LoggingLevel(l)}, nil
}
func (s *DBSink) Write(ctx context.Context, e LogEvent) error {
if e.Level < s.minLevel {
return nil
}
data, err := json.Marshal(e.Data)
if err != nil {
return fmt.Errorf("db sink: marshal fields: %w", err)
}
query := fmt.Sprintf(
`INSERT INTO %s (level, message, data, trace_id, created_at) VALUES ($1, $2, $3, $4, $5)`,
s.table,
)
if _, err = s.db.Exec(ctx, query,
e.Level,
e.Msg,
data, // []byte → JSONB (pgx native mapping, no string cast)
e.TraceID,
e.Time.UTC(),
); err != nil {
return fmt.Errorf("db sink: insert: %w", err)
}
return nil
}
func (s *DBSink) Close() error { return nil }
func (s *DBSink) Name() string {
return s.name
}
package logger
import (
"context"
"fmt"
"os"
"path/filepath"
"sync"
"time"
)
type FileSink struct {
name string
mu sync.Mutex
dir string
file *os.File
currentDate string
minLevel LoggingLevel
}
// NewFileSink opens (or creates/appends) the file at path and returns a reference to FileSink
func NewFileSink(cfg Config) (*FileSink, error) {
l := cfg.FileLoggingLevel
logDir := cfg.LogDirPath
if err := os.MkdirAll(logDir, 0o755); err != nil {
return nil, fmt.Errorf("failed to create logs directory %q: %w", logDir, err)
}
probe := logDir + "/.write_probe"
f, err := os.OpenFile(probe, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
if err != nil {
return nil, fmt.Errorf("directory %q is not writable: %w", logDir, err)
}
f.Close()
os.Remove(probe)
return &FileSink{name: "filesink", dir: logDir, minLevel: LoggingLevel(l)}, nil
}
func (s *FileSink) logFilePath(t time.Time) string {
return filepath.Join(
s.dir,
fmt.Sprintf("%d", t.Year()),
fmt.Sprintf("%02d", t.Month()),
fmt.Sprintf("%s.log", t.Format("02")),
)
}
func (s *FileSink) rotateFileIfNeeded(t time.Time) error {
dateStr := fmt.Sprintf("%d-%02d-%02d", t.Year(), t.Month(), t.Day())
if s.currentDate == dateStr {
return nil
}
if s.file != nil {
if err := s.file.Sync(); err != nil {
return fmt.Errorf("sync before rotate: %w", err)
}
if err := s.file.Close(); err != nil {
return fmt.Errorf("close before rotate: %w", err)
}
s.file = nil
}
path := s.logFilePath(t)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("create log subdirectory %q: %w", filepath.Dir(path), err)
}
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
return fmt.Errorf("open %q: %w", path, err)
}
s.file = f
s.currentDate = dateStr
return nil
}
func (s *FileSink) Write(_ context.Context, e LogEvent) error {
if e.Level < s.minLevel {
return nil
}
line := formatLogLine(e)
s.mu.Lock()
defer s.mu.Unlock()
if err := s.rotateFileIfNeeded(e.Time); err != nil {
return err
}
if _, err := fmt.Fprint(s.file, line); err != nil {
return fmt.Errorf("write: %w", err)
}
return nil
}
func (s *FileSink) Close() error {
s.mu.Lock()
defer s.mu.Unlock()
if s.file == nil {
return nil
}
if err := s.file.Sync(); err != nil {
return fmt.Errorf("sync: %w", err)
}
err := s.file.Close()
s.file = nil
return err
}
func (s *FileSink) Name() string {
return s.name
}
package logger
import (
"context"
"encoding/json"
"fmt"
"io"
"maps"
"os"
"sync"
"time"
"codeberg.org/at375/patent-flow/internal/metrics"
)
var Now = time.Now // to replace during testing with logger.Now = ...
type Logger interface {
Debug(ctx context.Context, msg string, fields map[string]any)
Info(ctx context.Context, msg string, fields map[string]any)
Warning(ctx context.Context, msg string, fields map[string]any)
Error(ctx context.Context, msg string, fields map[string]any)
Critical(ctx context.Context, msg string, fields map[string]any)
// Shutdown drains all in-flight events and closes every sink.
// Pass a context with a deadline to avoid blocking forever.
// Safe to call multiple times - only the first call has effect.
Shutdown(ctx context.Context) error
}
type LoggingLevel int
const (
LevelDebug LoggingLevel = iota
LevelInfo
LevelWarning
LevelError
LevelCritical
)
var levelNames = map[LoggingLevel]string{
LevelDebug: "DEBUG",
LevelInfo: "INFO",
LevelWarning: "WARNING",
LevelError: "ERROR",
LevelCritical: "CRITICAL",
}
func (l LoggingLevel) String() string {
if name, ok := levelNames[l]; ok {
return name
}
return fmt.Sprintf("LEVEL(%d)", int(l))
}
type LogEvent struct {
Level LoggingLevel `json:"level"`
Msg string `json:"msg"`
Data map[string]any `json:"data,omitempty"`
Time time.Time `json:"time"`
TraceID string `json:"trace_id,omitempty"`
Service string `json:"service,omitempty"`
}
// AsyncLogger fans events from a single dispatch channel to N sink workers
// concurrently. Never blocks callers - full channels trigger the fallback.
type AsyncLogger struct {
ch chan LogEvent
sinkWorkers []*sinkWorker
fallback io.Writer // target when the main channel or a sink channel is full
traceIDKey any // context key used to extract a trace/request ID
dispatchWg sync.WaitGroup
shutdownOnce sync.Once
}
// chBuffer - capacity of the main dispatch channel (e.g. 4096)
// sinkBuffer - per-sink channel capacity; tune per sink speed (e.g. 1024)
// sinks - list of output destinations
func NewAsyncLogger(chBuffer int, sinkBuffer int, sinks []Sink, fallback io.Writer, traceIDKey any) *AsyncLogger {
if fallback == nil {
fallback = os.Stderr
}
l := &AsyncLogger{
ch: make(chan LogEvent, chBuffer),
fallback: fallback,
traceIDKey: traceIDKey,
}
for _, s := range sinks {
l.sinkWorkers = append(l.sinkWorkers, newSinkWorker(s, sinkBuffer))
}
l.dispatchWg.Add(1)
go l.dispatch()
return l
}
func (l *AsyncLogger) dispatch() {
defer l.dispatchWg.Done()
for event := range l.ch {
for _, sw := range l.sinkWorkers {
select {
case sw.ch <- event:
default:
// Sink worker's buffer is full; fall back rather than block.
metrics.LoggerDropped.WithLabelValues(sw.sink.Name()).Inc()
l.writeFallback(event)
}
}
}
}
func (l *AsyncLogger) enqueue(ctx context.Context, level LoggingLevel, service string, msg string, fields map[string]any) {
// Need a copy since concurrent map reads in sinks
// and writes in the caller are creating a race.
var fieldsCopy map[string]any
if len(fields) > 0 {
fieldsCopy = make(map[string]any, len(fields))
maps.Copy(fieldsCopy, fields)
}
event := LogEvent{
Level: level,
Msg: msg,
Service: service,
Data: fieldsCopy,
Time: Now(), // injectable for testing
}
if l.traceIDKey != nil {
if id, ok := ctx.Value(l.traceIDKey).(string); ok {
event.TraceID = id
}
}
select {
case l.ch <- event:
default:
metrics.LoggerDropped.WithLabelValues("dispatch").Inc()
l.writeFallback(event)
}
}
func (l *AsyncLogger) writeFallback(e LogEvent) {
b, _ := json.Marshal(e)
if _, err := fmt.Fprintf(l.fallback, "%s\n", b); err != nil {
metrics.LoggerFallbackFailed.Inc()
fmt.Fprintf(os.Stderr, "LOG FALLBACK WRITE ERROR: %v event - %s\n", err, b)
}
}
// Shutdown drains the logger and closes all sinks gracefully.
// Safe to call multiple times, only the first call has effect.
// Pass a context with a deadline to bound the shutdown time.
func (l *namedLogger) Shutdown(ctx context.Context) error {
var shutdownErr error
l.parent.shutdownOnce.Do(func() {
close(l.parent.ch)
done := make(chan struct{})
go func() {
l.parent.dispatchWg.Wait()
for _, sw := range l.parent.sinkWorkers {
sw.close(ctx)
}
close(done)
}()
select {
case <-done:
case <-ctx.Done():
shutdownErr = fmt.Errorf("logger shutdown timed out: %w", ctx.Err())
}
})
return shutdownErr
}
type namedLogger struct {
parent *AsyncLogger
service string
}
func (l *AsyncLogger) Named(service string) Logger {
return &namedLogger{parent: l, service: service}
}
func (l *namedLogger) Debug(ctx context.Context, msg string, fields map[string]any) {
l.parent.enqueue(ctx, LevelDebug, l.service, msg, fields)
}
func (l *namedLogger) Info(ctx context.Context, msg string, fields map[string]any) {
l.parent.enqueue(ctx, LevelInfo, l.service, msg, fields)
}
func (l *namedLogger) Warning(ctx context.Context, msg string, fields map[string]any) {
l.parent.enqueue(ctx, LevelWarning, l.service, msg, fields)
}
func (l *namedLogger) Error(ctx context.Context, msg string, fields map[string]any) {
l.parent.enqueue(ctx, LevelError, l.service, msg, fields)
}
func (l *namedLogger) Critical(ctx context.Context, msg string, fields map[string]any) {
l.parent.enqueue(ctx, LevelCritical, l.service, msg, fields)
}
package logger
import (
"context"
"encoding/json"
"fmt"
"os"
"sync"
"codeberg.org/at375/patent-flow/internal/metrics"
)
// `Write` is called from the sink's dedicated goroutine only -
// implementations do not need to be safe against concurrent `Write` calls
// (unless you need a direct `Write` call to the sink from outside the `sinkWorker` for some reason).
// `Close` is always called after the last `Write` has returned
// (guaranteed by `sinkWorker` waiting on `run()` to exit), so no `Write` + `Close` race is possible.
//
// This means though, that `Write` should respect it's context,
// because a `Write` that ignores cancellation will cause deadlined shutdown to block
// until all `Write` calls for events left in buffer complete naturally regardless of the context timeout.
//
// Close is called once during shutdown, after the sink's channel is drained.
// Implementations' Close should flush any buffers and release resources.
type Sink interface {
Write(ctx context.Context, event LogEvent) error
Close() error
Name() string
}
type sinkWorker struct {
ctx context.Context
cancel context.CancelFunc
sink Sink
ch chan LogEvent
wg sync.WaitGroup
}
func newSinkWorker(sink Sink, bufSize int) *sinkWorker {
ctx, cancel := context.WithCancel(context.Background())
sw := &sinkWorker{
ctx: ctx,
cancel: cancel,
sink: sink,
ch: make(chan LogEvent, bufSize),
}
sw.wg.Add(1)
go sw.run()
return sw
}
func (sw *sinkWorker) run() {
defer sw.wg.Done()
for event := range sw.ch {
if err := sw.sink.Write(sw.ctx, event); err != nil {
metrics.LoggerFailed.WithLabelValues(sw.sink.Name()).Inc()
b, _ := json.Marshal(event)
fmt.Fprintf(os.Stderr, "sink write error: %v - event: %s\n", err, b)
}
}
}
func (sw *sinkWorker) close(ctx context.Context) error {
defer sw.cancel()
close(sw.ch)
waitDone := make(chan struct{})
go func() {
sw.wg.Wait()
close(waitDone)
}()
select {
case <-waitDone:
case <-ctx.Done():
sw.cancel()
<-waitDone
}
return sw.sink.Close()
}
func formatLogLine(e LogEvent) string {
t := e.Time
line := fmt.Sprintf("[%d-%02d-%02d %02d:%02d:%02d,%03d] - %s - %s - %s\n",
t.Year(), t.Month(), t.Day(),
t.Hour(), t.Minute(), t.Second(),
t.Nanosecond()/1e6,
e.Level.String(),
e.Service,
e.Msg,
)
if len(e.Data) == 0 {
return line
}
b, err := json.Marshal(e.Data)
if err != nil {
return line
}
return line + " └─ " + string(b) + "\n"
}
package logger
import (
"context"
"fmt"
"os"
)
type StdoutSink struct {
name string
minLevel LoggingLevel
}
func NewStdoutSink(cfg Config) (*StdoutSink, error) {
l := cfg.StdoutLoggingLevel
return &StdoutSink{name: "stdoutsink", minLevel: LoggingLevel(l)}, nil
}
func (s *StdoutSink) Write(ctx context.Context, e LogEvent) error {
if e.Level < s.minLevel {
return nil
}
line := formatLogLine(e)
_, err := fmt.Fprint(os.Stdout, line)
if err != nil {
return fmt.Errorf("stdout sink: %w", err)
}
return nil
}
func (s *StdoutSink) Close() error { return nil }
func (s *StdoutSink) Name() string {
return s.name
}
package grpchelper
import (
grpc_prometheus "github.com/grpc-ecosystem/go-grpc-prometheus"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
func GetGrpcConn(addr, port string) (*grpc.ClientConn, error) {
conn, err := grpc.NewClient(
addr+":"+port,
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithUnaryInterceptor(grpc_prometheus.UnaryClientInterceptor),
grpc.WithStreamInterceptor(grpc_prometheus.StreamClientInterceptor),
)
return conn, err
}
package mapper
import (
"encoding/json"
"errors"
"fmt"
"time"
"codeberg.org/at375/patent-flow/common/types"
"codeberg.org/at375/patent-flow/gen/sqlcgen"
"codeberg.org/at375/patent-flow/internal/model"
"codeberg.org/at375/pfjobrunner/gen/pbmodel"
)
// --- Single Mappers ---
func AgentFromDB(agent sqlcgen.Agent) model.Agent {
return model.Agent{
ID: agent.ID,
Name: agent.Name,
Email: agent.Email,
Address: agent.Address,
Postcode: agent.Postcode,
Country: agent.Country,
CreatedAt: agent.CreatedAt,
UpdatedAt: agent.UpdatedAt,
}
}
func ApplicantFromDB(applicant sqlcgen.Applicant) model.Applicant {
return model.Applicant{
ID: applicant.ID,
Name: applicant.Name,
Address: applicant.Address,
Postcode: applicant.Postcode,
Country: applicant.Country,
CreatedAt: applicant.CreatedAt,
UpdatedAt: applicant.UpdatedAt,
}
}
func PatentFromDB(patent any) (model.Patent, error) {
switch v := patent.(type) {
case sqlcgen.CreateOrGetPatentRow:
return model.Patent{
ID: v.ID,
Name: v.Name,
Number: v.Number,
PubDate: v.PubDate,
Countries: v.Countries,
PriorityDate: v.PriorityDate,
ApplicantID: v.ApplicantID,
GazetteID: v.GazetteID,
DeadlineDate: v.DeadlineDate,
CreatedAt: v.CreatedAt,
UpdatedAt: v.UpdatedAt,
}, nil
case sqlcgen.GetPatentRow:
return model.Patent{
ID: v.ID,
Name: v.Name,
Number: v.Number,
PubDate: v.PubDate,
Countries: v.Countries,
PriorityDate: v.PriorityDate,
ApplicantID: v.ApplicantID,
GazetteID: v.GazetteID,
DeadlineDate: v.DeadlineDate,
CreatedAt: v.CreatedAt,
UpdatedAt: v.UpdatedAt,
}, nil
case sqlcgen.GetPatentsByGazetteRow:
return model.Patent{
ID: v.ID,
Name: v.Name,
Number: v.Number,
PubDate: v.PubDate,
Countries: v.Countries,
PriorityDate: v.PriorityDate,
ApplicantID: v.ApplicantID,
GazetteID: v.GazetteID,
DeadlineDate: v.DeadlineDate,
CreatedAt: v.CreatedAt,
UpdatedAt: v.UpdatedAt,
}, nil
default:
return model.Patent{}, fmt.Errorf(
"mapper: can't convert type %T into model.Patent",
patent,
)
}
}
func GazetteFromDB(gazette sqlcgen.Gazette) model.Gazette {
return model.Gazette{
ID: gazette.ID,
Year: gazette.Year,
Name: gazette.Name,
Date: gazette.Date,
Count: gazette.Count,
UpdatedAt: gazette.UpdatedAt,
}
}
func PatentUrlFromDB(patentUrl sqlcgen.PatentUrl) types.PatentUrl {
return types.PatentUrl{
ID: patentUrl.ID,
GazetteID: patentUrl.GazetteID,
URL: patentUrl.Url,
Status: patentUrl.Status,
Name: patentUrl.Name,
Attempts: patentUrl.Attempts,
MaxAttempts: patentUrl.MaxAttempts,
}
}
func UserFromDB(user sqlcgen.User) model.User {
return model.User{
ID: user.ID,
Email: user.Email,
Name: user.Name,
PasswordHash: user.PasswordHash,
Role: user.Role,
}
}
// --- Bulk Mappers ---
func AgentsFromDB(agents []sqlcgen.Agent) []model.Agent {
modelAgents := make([]model.Agent, len(agents))
for i, agent := range agents {
modelAgents[i] = AgentFromDB(agent)
}
return modelAgents
}
func ApplicantsFromDB(applicants []sqlcgen.Applicant) []model.Applicant {
modelApplicants := make([]model.Applicant, len(applicants))
for i, applicant := range applicants {
modelApplicants[i] = ApplicantFromDB(applicant)
}
return modelApplicants
}
func PatentsFromDB(patents []any) ([]model.Patent, error) {
modelPatents := make([]model.Patent, len(patents))
for i, patent := range patents {
p, err := PatentFromDB(patent)
if err != nil {
return nil, err
}
modelPatents[i] = p
}
return modelPatents, nil
}
func GazettesFromDB(gazettes []sqlcgen.Gazette) []model.Gazette {
modelGazettes := make([]model.Gazette, len(gazettes))
for i, gazette := range gazettes {
modelGazettes[i] = GazetteFromDB(gazette)
}
return modelGazettes
}
func PatentUrlsFromDB(patentUrls []sqlcgen.PatentUrl) []types.PatentUrl {
modelPatentUrls := make([]types.PatentUrl, len(patentUrls))
for i, patentUrl := range patentUrls {
modelPatentUrls[i] = PatentUrlFromDB(patentUrl)
}
return modelPatentUrls
}
// --- Proto Mappers ---
func PatentFromProtoPackage(pkg *pbmodel.PatentPackage) (types.Patent, error) {
if pkg == nil {
return types.Patent{}, errors.New("pkg is a nil pointer")
}
pubDate, err := time.Parse("2006-01-02", pkg.Patent.PubDate)
if err != nil {
return types.Patent{}, err
}
priorDate, err := time.Parse("2006-01-02", pkg.Patent.PriorityDate)
if err != nil {
return types.Patent{}, err
}
return types.Patent{
Name: pkg.Patent.Name,
Number: pkg.Patent.Number,
PubDate: pubDate,
PriorityDate: priorDate,
Countries: pkg.Patent.Countries,
}, nil
}
func ApplicantFromProtoPackage(pkg *pbmodel.PatentPackage) (types.Applicant, error) {
if pkg == nil {
return types.Applicant{}, errors.New("pkg is a nil pointer")
}
return types.Applicant{
Name: pkg.Applicant.Name,
Address: pkg.Applicant.Address,
Postcode: pkg.Applicant.Postcode,
Country: pkg.Applicant.Country,
}, nil
}
func AgentsFromProtoPackage(pkg *pbmodel.PatentPackage) ([]types.Agent, error) {
if pkg == nil {
return nil, errors.New("pkg is a nil pointer")
}
agents := make([]types.Agent, 0, 3)
for _, agent := range pkg.Agents {
a := types.Agent{
Name: agent.Name,
Address: agent.Address,
Postcode: agent.Postcode,
Country: agent.Country,
}
agents = append(agents, a)
}
return agents, nil
}
func WorkspaceFromRow(r sqlcgen.Workspace) model.Workspace {
return model.Workspace{
ID: r.ID,
UserID: r.UserID,
Name: r.Name,
CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt,
}
}
func WorkspaceNodeFromRow(r sqlcgen.WorkspaceNode) model.WorkspaceNode {
meta := json.RawMessage(r.Metadata)
if meta == nil {
meta = json.RawMessage(`{}`)
}
return model.WorkspaceNode{
ID: r.ID,
WorkspaceID: r.WorkspaceID,
NodeType: model.NodeType(r.NodeType),
EntityID: r.EntityID,
PosX: r.PosX,
PosY: r.PosY,
Width: r.Width,
Height: r.Height,
Metadata: meta,
CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt,
}
}
func WorkspaceEdgeFromRow(r sqlcgen.WorkspaceEdge) model.WorkspaceEdge {
return model.WorkspaceEdge{
ID: r.ID,
WorkspaceID: r.WorkspaceID,
NodeID1: r.NodeID1,
NodeID2: r.NodeID2,
EdgeType: model.EdgeType(r.EdgeType),
}
}
package proxyhelper
import (
"bufio"
"fmt"
"math/rand"
"os"
"sync"
)
type ProxyPool struct {
ProxyFilePath string
proxies []string
mu sync.RWMutex
}
type ProxyProvider interface {
GetRandom() (string, error)
Reload() error
}
func NewProxyPool(path string) (*ProxyPool, error) {
p := &ProxyPool{ProxyFilePath: path}
if err := p.Reload(); err != nil {
return nil, err
}
return p, nil
}
func (p *ProxyPool) GetRandom() (string, error) {
p.mu.RLock()
defer p.mu.RUnlock()
if len(p.proxies) == 0 {
return "", fmt.Errorf("no proxies loaded; check file path or content")
}
randomIndex := rand.Intn(len(p.proxies))
return p.proxies[randomIndex], nil
}
func (p *ProxyPool) Reload() error {
f, err := os.Open(p.ProxyFilePath)
if err != nil {
return fmt.Errorf("failed to open proxy file: %w", err)
}
defer f.Close()
var newList []string
scanner := bufio.NewScanner(f)
for scanner.Scan() {
if line := scanner.Text(); line != "" {
newList = append(newList, line)
}
}
if len(newList) == 0 {
return fmt.Errorf("proxy file is empty, skipping update")
}
p.mu.Lock()
p.proxies = newList
p.mu.Unlock()
return nil
}
package uagenerator
import (
"math/rand"
"sync"
"time"
)
var (
Default = New(
rand.New(
rand.NewSource(time.Now().UnixNano()),
),
)
)
type Randomizer interface {
Seed(n int64)
Intn(n int) int
}
type UARand struct {
Randomizer
UserAgents []string
mutex sync.Mutex
}
func (u *UARand) GetRandom() string {
u.mutex.Lock()
n := u.Intn(len(u.UserAgents))
u.mutex.Unlock()
return u.UserAgents[n]
}
func GetRandom() string {
return Default.GetRandom()
}
func New(r Randomizer) *UARand {
return &UARand{
Randomizer: r,
UserAgents: UserAgents,
mutex: sync.Mutex{},
}
}